From 529c91d9e9285a9bd7431d5283cbc0ad1c49f5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 24 Sep 2018 15:42:03 +0200 Subject: [PATCH] Notifications module --- app/coffee/app.coffee | 16 ++ app/coffee/modules/base.coffee | 2 + app/coffee/modules/resources.coffee | 1 + .../user-settings/live-notifications.coffee | 2 +- .../user-settings/web-notifications.coffee | 121 +++++++++++ app/locales/taiga/locale-en.json | 30 ++- .../dropdown-notifications.directive.coffee | 56 +++++ .../dropdown-notifications.jade | 26 +++ .../navigation-bar/navigation-bar.jade | 5 + .../navigation-bar/navigation-bar.scss | 111 +++++++++- .../notifications-list.directive.coffee | 33 +++ .../notifications-list.jade | 36 ++++ .../notifications.controller.coffee | 101 +++++++++ app/modules/notifications/notifications.jade | 10 + .../notifications/notifications.module.coffee | 20 ++ app/modules/notifications/notifications.scss | 128 ++++++++++++ .../notifications.service.coffee | 191 ++++++++++++++++++ .../resources/users-resource.service.coffee | 35 ++++ ...imeline-pagination-sequence.service.coffee | 5 +- .../user-timeline/user-timeline.jade | 2 +- .../includes/modules/user-settings-menu.jade | 3 + .../web-notifications-table.jade | 8 + app/partials/user/web-notifications.jade | 24 +++ app/styles/core/typography.scss | 1 + .../mail-notifications-table.scss | 8 +- app/svg/sprite.svg | 8 + app/themes/high-contrast/variables.scss | 1 + app/themes/material-design/variables.scss | 1 + app/themes/taiga/variables.scss | 1 + 29 files changed, 975 insertions(+), 11 deletions(-) create mode 100644 app/coffee/modules/user-settings/web-notifications.coffee create mode 100644 app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.directive.coffee create mode 100644 app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.jade create mode 100644 app/modules/notifications/notifications-list/notifications-list.directive.coffee create mode 100644 app/modules/notifications/notifications-list/notifications-list.jade create mode 100644 app/modules/notifications/notifications.controller.coffee create mode 100644 app/modules/notifications/notifications.jade create mode 100644 app/modules/notifications/notifications.module.coffee create mode 100644 app/modules/notifications/notifications.scss create mode 100644 app/modules/notifications/notifications.service.coffee create mode 100644 app/partials/includes/modules/user-settings/web-notifications-table.jade create mode 100644 app/partials/user/web-notifications.jade diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 85f41dc1..e5d66a4e 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -465,6 +465,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "user/mail-notifications.html"}) $routeProvider.when("/user-settings/live-notifications", {templateUrl: "user/live-notifications.html"}) + $routeProvider.when("/user-settings/web-notifications", + {templateUrl: "user/web-notifications.html"}) $routeProvider.when("/change-email/:email_token", {templateUrl: "user/change-email.html"}) $routeProvider.when("/cancel-account/:cancel_token", @@ -487,6 +489,19 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven } ) + # Notifications + $routeProvider.when("/notifications", + { + templateUrl: "notifications/notifications.html", + loader: true, + access: { + requiresLogin: true + }, + controller: "Notifications", + controllerAs: "vm" + } + ) + $routeProvider.when("/profile/:slug", { templateUrl: "profile/profile.html", @@ -937,6 +952,7 @@ modules = [ "taigaExternalApps", "taigaDiscover", "taigaHistory", + "taigaNotifications", "taigaWikiHistory", "taigaEpics", "taigaUtils" diff --git a/app/coffee/modules/base.coffee b/app/coffee/modules/base.coffee index b37ef637..8d05a320 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -123,8 +123,10 @@ urls = { "user-settings-user-project-settings": "/user-settings/user-project-settings" "user-settings-mail-notifications": "/user-settings/mail-notifications" "user-settings-live-notifications": "/user-settings/live-notifications" + "user-settings-web-notifications": "/user-settings/web-notifications" "user-settings-contrib": "/user-settings/contrib/:plugin" + "notifications": "/notifications" } init = ($log, $navurls) -> diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 694915f9..40509a98 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -46,6 +46,7 @@ urls = { # User - Notification "permissions": "/permissions" "notify-policies": "/notify-policies" + "notifications": "/web-notifications" # User Project Settings "user-project-settings": "/user-project-settings" diff --git a/app/coffee/modules/user-settings/live-notifications.coffee b/app/coffee/modules/user-settings/live-notifications.coffee index 440c8ef5..aa138586 100644 --- a/app/coffee/modules/user-settings/live-notifications.coffee +++ b/app/coffee/modules/user-settings/live-notifications.coffee @@ -44,7 +44,7 @@ class UserLiveNotificationsController extends mixOf(taiga.Controller, taiga.Page ] constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @navUrls, @auth, @errorHandlingService) -> - @scope.sectionName = "USER_SETTINGS.NOTIFICATIONS.LIVE_SECTION_NAME" + @scope.sectionName = "USER_SETTINGS.EVENTS.LIVE_SECTION_NAME" @scope.user = @auth.getUser() promise = @.loadInitialData() promise.then null, @.onInitialDataError.bind(@) diff --git a/app/coffee/modules/user-settings/web-notifications.coffee b/app/coffee/modules/user-settings/web-notifications.coffee new file mode 100644 index 00000000..eb5d4400 --- /dev/null +++ b/app/coffee/modules/user-settings/web-notifications.coffee @@ -0,0 +1,121 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: modules/user-settings/live-notifications.coffee +### + +taiga = @.taiga +mixOf = @.taiga.mixOf +bindOnce = @.taiga.bindOnce + +module = angular.module("taigaUserSettings") + + +############################################################################# +## User Web Notifications Controller +############################################################################# + +class UserWebNotificationsController extends mixOf(taiga.Controller, taiga.PageMixin) + @.$inject = [ + "$scope", + "$tgResources", + "$tgAuth" + ] + + constructor: (@scope, @rs, @auth) -> + @scope.sectionName = "USER_SETTINGS.EVENTS.SECTION_NAME" + @scope.user = @auth.getUser() + promise = @.loadInitialData() + promise.then null, @.onInitialDataError.bind(@) + + loadInitialData: -> + return @rs.notifyPolicies.list().then (notifyPolicies) => + @scope.notifyPolicies = notifyPolicies + return notifyPolicies + +module.controller("UserWebNotificationsController", UserWebNotificationsController) + + +############################################################################# +## User Web Notifications Directive +############################################################################# + +UserWebNotificationsDirective = () -> + link = ($scope, $el, $attrs) -> + $scope.$on "$destroy", -> + $el.off() + + return {link:link} + +module.directive("tgUserWebNotifications", UserWebNotificationsDirective) + + +############################################################################# +## User Web Notifications List Directive +############################################################################# + +UserWebNotificationsListDirective = ($repo, $confirm, $compile) -> + template = _.template(""" + <% _.each(notifyPolicies, function (notifyPolicy, index) { %> +
+
<%- notifyPolicy.project_name %>
+
+
+ checked="checked" <% } %> + name="policy-<%- notifyPolicy.id %>" id="policy-<%- notifyPolicy.id %>"/> +
+ + +
+
+
+ <% }) %> + """) + + link = ($scope, $el, $attrs) -> + render = -> + $el.off() + + ctx = {notifyPolicies: $scope.notifyPolicies} + html = template(ctx) + + $el.html($compile(html)($scope)) + + $el.on "click", ".check", (event) -> + target = angular.element(event.currentTarget) + policyIndex = target.data('index') + policy = $scope.notifyPolicies[policyIndex] + policy.web_notify_level = !policy.web_notify_level + + onSuccess = -> + $confirm.notify("success") + target.find("input").prop("checked", policy.web_notify_level) + + onError = -> + $confirm.notify("error") + + $repo.save(policy).then(onSuccess, onError) + + $scope.$on "$destroy", -> + $el.off() + + bindOnce($scope, $attrs.ngModel, render) + + return {link:link} + +module.directive("tgUserWebNotificationsList", +["$tgRepo", "$tgConfirm", "$compile", UserWebNotificationsListDirective]) diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index b318314f..2bd9067e 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -1550,7 +1550,8 @@ "USER_PROFILE": "User profile", "CHANGE_PASSWORD": "Change password", "EMAIL_NOTIFICATIONS": "Email notifications", - "DESKTOP_NOTIFICATIONS": "Desktop notifications" + "DESKTOP_NOTIFICATIONS": "Desktop notifications", + "EVENTS": "Events" }, "NOTIFICATIONS": { "LIVE_SECTION_NAME": "Desktop Notifications", @@ -1569,6 +1570,13 @@ "COLUMN_PROJECT": "Project", "COLUMN_STARTPAGE": "Start page", "DEFAULT_VALUE": "Default" + }, + "EVENTS": { + "SECTION_NAME": "Events", + "SECTION_DESCRIPTION": "Important events in Taiga header", + "SECTION_DESCRIPTION_EXPANDED": "(direct mentions, updates in items that you are watching...)", + "COLUMN_ENABLED": "Enabled", + "COLUMN_PROJECT": "Project" } }, "USER_PROFILE": { @@ -1666,7 +1674,25 @@ "US_REMOVED_FROM_MILESTONE": "{{username}} has added the US {{obj_name}} to the backlog", "BLOCKED": "{{username}} has blocked {{obj_name}}", "UNBLOCKED": "{{username}} has unblocked {{obj_name}}", - "NEW_USER": "{{username}} has joined Taiga" + "NEW_USER": "{{username}} has joined Taiga", + "ITEM_TYPES": { + "USERSTORY": "User Story", + "ISSUE": "Issue", + "TASK": "Task" + } + }, + "EVENTS": { + "TITLE": "Events", + "MY_EVENTS": "My events", + "DISMISS_ALL": "Dismiss all", + "VIEW_ALL": "View all", + "NO_NEW_EVENTS": "No new events", + "ASSIGNED_YOU": "{{username}} assigned you to {{obj_name}}", + "ADDED_YOU_AS_WATCHER": "{{username}} added you as watcher on {{obj_name}}", + "ADDED_YOU_AS_MEMBER": "{{username}} added you as member", + "MENTIONED_YOU": "{{username}} mentioned you on {{obj_name}}", + "MENTIONED_YOU_IN_COMMENT": "{{username}} mentioned you in a comment on {{obj_name}}", + "COMMENTED": "{{username}} has commented on {{obj_name}}" }, "LEGAL": { "TERMS_OF_SERVICE_AND_PRIVACY_POLICY_AD": "When creating a new account, you agree to our terms of service and privacy policy.", diff --git a/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.directive.coffee b/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.directive.coffee new file mode 100644 index 00000000..b6094c65 --- /dev/null +++ b/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.directive.coffee @@ -0,0 +1,56 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: navigation-bar/dropdown-notifications/dropdown-notifications.directive.coffee +### + +timeout = @.taiga.timeout + +DropdownNotificationsDirective = ($rootScope, notificationsService, currentUserService) -> + link = ($scope, $el, $attrs, $ctrl) -> + $scope.notificationsList = [] + $scope.loading = true + + $scope.$on "notifications:loaded", (event, total) -> + $scope.loading = false + if $scope.total != undefined && total > $scope.total + $scope.newEvent = true + timeout 100, -> + $scope.total = total + $scope.$apply() + timeout 2000, -> + $scope.newEvent = false + else + $scope.total = total + + $scope.$on "notifications:loading", () -> + $scope.loading = true + + $scope.setAllAsRead = () -> + notificationsService.setNotificationsAsRead().then -> + $rootScope.$emit("notifications:updated") + + directive = { + templateUrl: "navigation-bar/dropdown-notifications/dropdown-notifications.html" + scope: true + link: link + } + + return directive + +angular.module("taigaNavigationBar") + .directive("tgDropdownNotifications", ["$rootScope", "tgNotificationsService", + "tgCurrentUserService", DropdownNotificationsDirective]) diff --git a/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.jade b/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.jade new file mode 100644 index 00000000..e391182e --- /dev/null +++ b/app/modules/navigation-bar/dropdown-notifications/dropdown-notifications.jade @@ -0,0 +1,26 @@ +a( + href="" + title="{{ 'EVENTS.TITLE' | translate }}" + tg-nav="notifications" + ng-mouseover="visible=true" +) + tg-svg(svg-icon="icon-bell") + div.counter(ng-if="total", ng-class="{'active': newEvent}") {{ total }} +div.navbar-dropdown-notifications( + ng-show="visible", + ng-mouseleave="visible=false" +) + .header + span.notifications-title {{ 'EVENTS.MY_EVENTS' | translate }} + a(href="", ng-click="setAllAsRead()") {{ 'EVENTS.DISMISS_ALL' | translate }} + a(tg-nav="notifications") {{ 'EVENTS.VIEW_ALL' | translate }} + + .notifications-wrapper + .empty(ng-if="!total && !loading") + span {{ 'EVENTS.NO_NEW_EVENTS' | translate }} + + tg-notifications-list#my-notifications.dropdown-notifications-list( + only-unread="true", + infinite-scroll-container="#my-notifications" + infinite-scroll-distance="1" + ) \ No newline at end of file diff --git a/app/modules/navigation-bar/navigation-bar.jade b/app/modules/navigation-bar/navigation-bar.jade index 7a252ee0..13f7c1df 100644 --- a/app/modules/navigation-bar/navigation-bar.jade +++ b/app/modules/navigation-bar/navigation-bar.jade @@ -54,4 +54,9 @@ nav.navbar(ng-if="vm.isEnabledHeader") tg-svg(svg-icon="icon-discover") div.topnav-dropdown-wrapper(ng-show="vm.projects.size", tg-dropdown-project-list) + + div.topnav-dropdown-wrapper( + tg-dropdown-notifications + ) + div.topnav-dropdown-wrapper(tg-dropdown-user) diff --git a/app/modules/navigation-bar/navigation-bar.scss b/app/modules/navigation-bar/navigation-bar.scss index 887bb87b..a2e3938b 100644 --- a/app/modules/navigation-bar/navigation-bar.scss +++ b/app/modules/navigation-bar/navigation-bar.scss @@ -37,7 +37,8 @@ $dropdown-width: 350px; } .nav-right { margin-left: auto; - a { + > a, + .topnav-dropdown-wrapper > a { color: $white; padding: .5rem 2rem; } @@ -47,7 +48,8 @@ $dropdown-width: 350px; transition: all .2s linear; } } - a { + > a, + .topnav-dropdown-wrapper > a { color: $white; display: inline-block; transition: all .2s linear; @@ -70,7 +72,7 @@ $dropdown-width: 350px; } } - img { + .user-avatar img { height: 2.5rem; margin-left: .5rem; vertical-align: middle; @@ -169,3 +171,106 @@ $dropdown-width: 350px; flex-direction: row; } } + + +@keyframes highlightFadeOut { + 0% { + background: $primary-light; + font-size: .75rem; + height: 22px; + left: 49px; + line-height: 22px; + top: -1px; + width: 22px; + } + 20% { + font-size: .7rem; + height: 20px; + left: 50px; + line-height: 20px; + top: 0; + width: 20px; + } + 100% { + background: $primary; + } +} + + +.topnav-dropdown-wrapper { + position: relative; + &:hover { + .navbar-dropdown-notifications { + animation: dropdownFade .2s cubic-bezier(.09, 0, .99, .01) both; + display: block; + } + } + .counter { + background: $primary; + border-radius: 50%; + color: $white; + font-size: .7rem; + height: 20px; + left: 50px; + line-height: 20px; + position: absolute; + text-align: center; + top: 0; + width: 20px; + &.active { + animation-duration: 2s; + animation-name: highlightFadeOut; + } + } + .navbar-dropdown-notifications { + $width: 450px; + background: $white; + border: 1px solid $whitish; + border-radius: 2px; + box-shadow: 0 0 3px 3px rgba($gray-lighter, .2); + color: $blackish; + display: none; + left: calc(50% - 450px/2); + margin-top: 1px; + min-width: 450px; + padding: 0; + position: absolute; + top: 2.4rem; + z-index: 999; + + .empty { + color: $gray-light; + padding: 2.5em 0; + text-align: center; + width: 100%; + } + .header { + @include arrow('bottom', $mass-white, $mass-white, 1, 8); + background: $mass-white; + color: $gray; + display: flex; + flex-direction: row; + padding: .6rem 0 .5rem .7rem; + .notifications-title { + flex-grow: 1; + text-transform: uppercase; + } + a { + color: $gray-light; + font-size: .9rem; + padding: 0 .7rem 0 0; + text-align: right; + } + a:hover { + color: $primary; + } + } + .notifications-wrapper { + min-height: 100px; + } + .notifications-list { + max-height: 400px; + overflow-y: auto; + } + } +} diff --git a/app/modules/notifications/notifications-list/notifications-list.directive.coffee b/app/modules/notifications/notifications-list/notifications-list.directive.coffee new file mode 100644 index 00000000..f420719b --- /dev/null +++ b/app/modules/notifications/notifications-list/notifications-list.directive.coffee @@ -0,0 +1,33 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: notifications/notifications-list/notifications-list.directive.coffee +### + +NotificationsListDirective = -> + return { + templateUrl: "notifications/notifications-list/notifications-list.html", + controller: "Notifications", + controllerAs: "vm", + bindToController: true, + scope: { + infiniteScrollContainer: "@", + infiniteScrollDistance: "=", + onlyUnread: "=onlyUnread" + } + } + +angular.module("taigaNotifications").directive("tgNotificationsList", NotificationsListDirective) diff --git a/app/modules/notifications/notifications-list/notifications-list.jade b/app/modules/notifications/notifications-list/notifications-list.jade new file mode 100644 index 00000000..aa445d51 --- /dev/null +++ b/app/modules/notifications/notifications-list/notifications-list.jade @@ -0,0 +1,36 @@ +section.notifications-list + div(ng-if="vm.loading") + div.spin + img( + src="/#{v}/svg/spinner-circle.svg" + alt="Loading..." + ) + div( + ng-if="!vm.loading" + infinite-scroll="vm.loadNotifications()" + infinite-scroll-disabled="vm.scrollDisabled" + ng-attr-infinite-scroll-container="vm.infiniteScrollContainer" + ng-attr-infinite-scroll-distance="vm.infiniteScrollDistance" + ) + .entry( + tg-repeat="notification in vm.notificationsList" + ng-class="{'new': !notification.get('read')}" + ) + .entry-avatar + // profile image with url + .profile-picture(ng-if="notification.getIn(['data', 'user', 'is_profile_visible'])") + a(tg-nav="user-profile:username=notification.getIn(['data', 'user', 'username'])", title="{{::notification.getIn(['data', 'user', 'name']) }}") + img( + tg-avatar="notification.getIn(['data', 'user'])" + alt="{{::notification.getIn(['data', 'user', 'name'])}}" + ) + // profile image without url + .profile-picture(ng-if="!notification.getIn(['data', 'user', 'is_profile_visible'])") + img( + tg-avatar="notification.getIn(['data', 'user'])" + alt="{{::notification.getIn(['data', 'user', 'name'])}}" + ) + .entry-content + p(tg-compile-html="notification.get('title_html')") + span.entry-project {{::notification.getIn(['data', 'project', 'name'])}} + .entry-date {{::notification.get('created') | momentFromNow}} \ No newline at end of file diff --git a/app/modules/notifications/notifications.controller.coffee b/app/modules/notifications/notifications.controller.coffee new file mode 100644 index 00000000..1dd520f5 --- /dev/null +++ b/app/modules/notifications/notifications.controller.coffee @@ -0,0 +1,101 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: notifications/notifications.controller.coffee +### + +taiga = @.taiga + +mixOf = @.taiga.mixOf +debounceLeading = @.taiga.debounceLeading + +class NotificationsController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) + @.$inject = [ + "$rootScope", + "$scope", + "tgNotificationsService" + "tgCurrentUserService", + "$tgEvents", + "$location" + "$window" + ] + + constructor: (@rootScope, @scope, @notificationsService, @currentUserService, @events, + @location, @window) -> + @.total = 0 + @.user = @currentUserService.getUser() + @.scrollDisabled = false + @.initList() + @.initializeSubscription() + @.loadNotifications() + + @rootScope.$on "notifications:updated", (event) => + @.reloadList() + + initList: ()-> + @.notificationsList = Immutable.List() + @.list = @notificationsService.getNotificationsList(@.user.get("id"), @.onlyUnread?) + @.loading = !@.list? + + reloadList: ()-> + @.initList() + @.loadNotifications() + + loadNotifications: () -> + @.scrollDisabled = true + @.loading = true + @scope.$emit("notifications:loading") + return @.list + .next() + .then (response) => + @.notificationsList = @.notificationsList.concat(response.get("items")) + + if response.get("next") + @.scrollDisabled = false + + @.total = response.get("total") + + @scope.$emit("notifications:loaded", @.total) + + @.loading = false + return @.notificationsList + + setAsRead: (notification, url) -> + @.loading = true + @scope.$emit("notifications:loading") + @notificationsService.setNotificationAsRead(notification.get("id")).then => + if @location.$$url == url + @window.location.reload() + else + @rootScope.$broadcast "notifications:updated" + @location.path(url) + + setAllAsRead: () -> + @.loading = true + @scope.$emit("notifications:loading") + @notificationsService.setNotificationsAsRead().then => + @rootScope.$emit("notifications:updated") + + initializeSubscription: -> + routingKey = "web_notifications.#{@.user.get("id")}" + randomTimeout = taiga.randomInt(700, 1000) + @events.subscribe( + @scope, + routingKey, + debounceLeading(randomTimeout, (message) => @rootScope.$broadcast "notifications:updated") + ) + +angular.module("taigaNotifications").controller("Notifications", NotificationsController) diff --git a/app/modules/notifications/notifications.jade b/app/modules/notifications/notifications.jade new file mode 100644 index 00000000..b9198ec3 --- /dev/null +++ b/app/modules/notifications/notifications.jade @@ -0,0 +1,10 @@ +div.wrapper + div.notifications-page.centered + header.header + h1.title {{ 'EVENTS.MY_EVENTS' | translate }} + a.action( + href="", + ng-if="!vm.notificationsList.get(0).get('read')", + ng-click="vm.setAllAsRead()") {{ 'EVENTS.DISMISS_ALL' | translate }} + span.action.disabled(ng-if="vm.notificationsList.get(0).get('read')") {{ 'EVENTS.DISMISS_ALL' | translate }} + tg-notifications-list() diff --git a/app/modules/notifications/notifications.module.coffee b/app/modules/notifications/notifications.module.coffee new file mode 100644 index 00000000..36caa545 --- /dev/null +++ b/app/modules/notifications/notifications.module.coffee @@ -0,0 +1,20 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: projects/projects.module.coffee +### + +angular.module("taigaNotifications", []) diff --git a/app/modules/notifications/notifications.scss b/app/modules/notifications/notifications.scss new file mode 100644 index 00000000..87f0b8fc --- /dev/null +++ b/app/modules/notifications/notifications.scss @@ -0,0 +1,128 @@ +.notifications-page { + margin-top: 1.5em; + min-width: initial; + padding-bottom: 5em; + .header { + align-items: center; + background: $mass-white; + display: flex; + margin: 0; + padding: .5em 1em; + } + .title { + color: $black; + flex-grow: 1; + font-size: 1.8em; + line-height: 1.8em; + margin: 0; + text-transform: uppercase; + } + + .action { + color: $primary; + font-size: 1em; + line-height: .75em; + margin-right: 1em; + &.disabled { + color: $gray-lighter; + } + &:not(.disabled):hover { + color: $primary-light; + } + } + .notifications-list .entry { + align-items: center; + font-size: .95rem; + margin: .5rem 0; + padding: 1rem .6rem; + &.new { + background-color: $primary-lighter; + } + .entry-content { + font-size: 1.05rem; + } + .entry-project { + max-width: initial; + text-overflow: initial; + white-space: initial; + } + .entry-avatar { + flex-basis: 3rem; + margin-right: 1rem; + width: 3rem; + } + .entry-date { + font-size: .85rem; + margin-right: .5rem; + } + } +} + +.notifications-list { + .entry { + border-bottom: 1px solid $whitish; + color: $blackish; + display: flex; + font-size: .9rem; + margin: 0 .8rem; + padding: .8rem 0; + position: relative; + p { + line-height: 1.25em; + margin-bottom: 0; + } + a { + @include font-type(bold); + &.user-link, + &.project-link { + color: $blackish; + } + &.object-link { + color: $primary; + } + &:hover { + color: $primary-light; + } + } + .entry-avatar { + border-radius: .1rem; + flex-basis: 2.5rem; + flex-shrink: 0; + margin-right: .7rem; + vertical-align: center; + width: 2.5rem; + img { + width: 100%; + } + } + .entry-content { + flex-grow: 1; + line-height: 1.1em; + margin-right: .7rem; + } + .entry-project { + @include font-type(bold); + color: $gray-lighter; + display: inline-block; + font-size: .9rem; + margin-top: .5em; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .entry-date { + font-size: .7rem; + white-space: nowrap; + } + } + .spin { + margin: 5% auto; + width: 3rem; + img { + @include loading-spinner; + max-height: 3rem; + max-width: 3rem; + } + } +} diff --git a/app/modules/notifications/notifications.service.coffee b/app/modules/notifications/notifications.service.coffee new file mode 100644 index 00000000..9ac6e812 --- /dev/null +++ b/app/modules/notifications/notifications.service.coffee @@ -0,0 +1,191 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: user-notification/user-notification/user-notification.service.coffee +### + +taiga = @.taiga + +class NotificationsService extends taiga.Service + @.$inject = [ + "tgResources", + "tgUserTimelinePaginationSequenceService", + "$translate", + "$tgNavUrls" + ] + + _notificationTypes = [ + { # Assigned to you + check: (notification) -> return notification.get('event_type') == 1 + key: 'EVENTS.ASSIGNED_YOU', + translate_params: ['username', 'obj_name'] + }, + { # Mentioned you in a object description + check: (notification) -> return notification.get('event_type') == 2 + key: 'EVENTS.MENTIONED_YOU', + translate_params: ['username', 'obj_name'], + }, + { # Added you as watcher + check: (notification) -> return notification.get('event_type') == 3 + key: 'EVENTS.ADDED_YOU_AS_WATCHER', + translate_params: ['username', 'obj_name'], + }, + { # Added you as member + check: (notification) -> return notification.get('event_type') == 4 + key: 'EVENTS.ADDED_YOU_AS_MEMBER', + translate_params: ['username'] + }, + { # Commented + check: (notification) -> return notification.get('event_type') == 5 + key: 'EVENTS.COMMENTED', + translate_params: ['username', 'obj_name'], + }, + { # Mentioned you in a comment + check: (notification) -> return notification.get('event_type') == 6 + key: 'EVENTS.MENTIONED_YOU_IN_COMMENT', + translate_params: ['username', 'obj_name'], + }, + ] + + _params = { + username: (notification) -> + user = notification.getIn(['data', 'user']) + if user.get('is_profile_visible') + title_attr = @translate.instant('COMMON.SEE_USER_PROFILE', {username: user.get('username')}) + url = @navUrls.resolve('user-profile', { + username: notification.getIn(['data', 'user', 'username']) + }) + return @._getLink(notification, url, user.get('name'), 'user-link', title_attr) + else + return @._getUsernameSpan(user.get('name')) + + project_name: (notification) -> + url = @navUrls.resolve('project', { + project: notification.getIn(['data', 'project', 'slug']) + }) + return @._getLink(notification, url, notification.getIn(["data", "project", "name"]), 'project-link') + + obj_name: (notification) -> + obj = @._getNotificationObject(notification) + url = @._getDetailObjUrl(notification, obj.get('content_type')) + text = '#' + obj.get('ref') + ' ' + obj.get('subject') + return @._getLink(notification, url, text, 'object-link' ) + } + + constructor: ( + @rs, + @userTimelinePaginationSequenceService, + @translate, + @navUrls + ) -> + + getNotificationsList: (userId, onlyUnread) -> + total = 0 + config = {} + config.fetch = (page) => + return @rs.users.getNotifications(userId, page, onlyUnread) + .then (response) -> + return response + + config.map = (obj) => @._addNotificationAttributes(obj) + return @userTimelinePaginationSequenceService.generate(config) + + setNotificationAsRead: (notificationId) -> + return @rs.users.setNotificationAsRead(notificationId) + + setNotificationsAsRead: () -> + return @rs.users.setNotificationsAsRead() + + _getNotificationObject: (notification) -> + if notification.get('data').get('obj') + return notification.get('data').get('obj') + + _getType: (notification) -> + return _.find _notificationTypes, (obj) -> + return obj.check(notification) + + _addNotificationAttributes: (notification) -> + event_type = notification.get('event_type') + + type = @._getType(notification) + + title = @._getTitle(notification, event_type, type) + + notification = notification.set('title_html', title) + notification = notification.set('obj', @._getNotificationObject(notification)) + + return notification + + _translateTitleParams: (param, notification, event) -> + return _params[param].call(this, notification, event) + + _getDetailObjUrl: (notification, contentType) -> + urlMapping = { + "issue": "project-issues-detail", + "task": "project-tasks-detail", + "userstory": "project-userstories-detail", + } + url = @navUrls.resolve(urlMapping[contentType], { + project: notification.getIn(['data', 'project', 'slug']), + ref: notification.getIn(['data', 'obj', 'ref']) + }) + + return url + + _getLink: (notification, url, text, css, title) -> + title = title || text + + span = $('') + .attr('ng-non-bindable', true) + .text(text) + + return $('') + .attr('title', title) + .attr('class', css) + .attr('ng-click', "vm.setAsRead(notification, \"#{url}\")") + .append(span) + .prop('outerHTML') + + _getUsernameSpan: (text) -> + title = title || text + + return $('') + .addClass('username') + .text(text) + .prop('outerHTML') + + _getParams: (notification, event_type, notification_type) -> + params = {} + + notification_type.translate_params.forEach (param) => + params[param] = @._translateTitleParams(param, notification, event_type) + return params + + _getTitle: (notification, event_type, notification_type) -> + params = @._getParams(notification, event_type, notification_type) + + paramsKeys = {} + Object.keys(params).forEach (key) -> paramsKeys[key] = '{{' +key + '}}' + + translation = @translate.instant(notification_type.key, paramsKeys) + + Object.keys(params).forEach (key) -> + find = '{{' +key + '}}' + translation = translation.replace(new RegExp(find, 'g'), params[key]) + + return translation + +angular.module("taigaNotifications").service("tgNotificationsService", NotificationsService) diff --git a/app/modules/resources/users-resource.service.coffee b/app/modules/resources/users-resource.service.coffee index e34d942e..06ea4a11 100644 --- a/app/modules/resources/users-resource.service.coffee +++ b/app/modules/resources/users-resource.service.coffee @@ -150,6 +150,41 @@ Resource = (urlsService, http, paginateResponseService) -> result = Immutable.fromJS(result) return paginateResponseService(result) + service.getNotifications = (userId, page, onlyUnread) -> + params = { + page: page + } + if onlyUnread + params['only_unread'] = true + + url = urlsService.resolve("notifications") + + return http.get(url, params, { + headers: { + 'x-lazy-pagination': true + } + }).then (result) -> + result = Immutable.fromJS(result) + paginateResponse = Immutable.Map({ + "data": result.get("data").get("objects"), + "next": !!result.get("headers")("x-pagination-next"), + "prev": !!result.get("headers")("x-pagination-prev"), + "current": result.get("headers")("x-pagination-current"), + "count": result.get("headers")("x-pagination-count"), + "total": result.get("data").get("total") + }) + return paginateResponse + + service.setNotificationAsRead = (notificationId) -> + url = "#{urlsService.resolve("notifications")}/#{notificationId}/set-as-read" + return http.patch(url).then (result) -> + return result + + service.setNotificationsAsRead = () -> + url = "#{urlsService.resolve("notifications")}/set-as-read" + return http.post(url).then (result) -> + return result + return () -> return {"users": service} diff --git a/app/modules/user-timeline/user-timeline-pagination-sequence/user-timeline-pagination-sequence.service.coffee b/app/modules/user-timeline/user-timeline-pagination-sequence/user-timeline-pagination-sequence.service.coffee index 52e40f1e..98f169a5 100644 --- a/app/modules/user-timeline/user-timeline-pagination-sequence/user-timeline-pagination-sequence.service.coffee +++ b/app/modules/user-timeline/user-timeline-pagination-sequence/user-timeline-pagination-sequence.service.coffee @@ -47,11 +47,14 @@ UserTimelinePaginationSequence = () -> if items.size < config.minItems && response.get("next") return getContent() - return Immutable.Map({ + pagination = Immutable.Map({ items: items, + total: response.get("total"), next: response.get("next") }) + return pagination + return { next: () -> next() } diff --git a/app/modules/user-timeline/user-timeline/user-timeline.jade b/app/modules/user-timeline/user-timeline/user-timeline.jade index 0ad599bb..04e5ceed 100644 --- a/app/modules/user-timeline/user-timeline/user-timeline.jade +++ b/app/modules/user-timeline/user-timeline/user-timeline.jade @@ -1,5 +1,5 @@ section.profile-timeline - div(ng-if="!vm.timelineList.size") + div(ng-if="vm.loading") div.spin img( src="/#{v}/svg/spinner-circle.svg" diff --git a/app/partials/includes/modules/user-settings-menu.jade b/app/partials/includes/modules/user-settings-menu.jade index 4e165e35..069de86a 100644 --- a/app/partials/includes/modules/user-settings-menu.jade +++ b/app/partials/includes/modules/user-settings-menu.jade @@ -16,6 +16,9 @@ section.admin-menu li#usersettingsmenu-live-notifications a(href="", tg-nav="user-settings-live-notifications", title="{{ 'USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS' | translate }}") span.title(translate="USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS") + li#usersettingsmenu-web-notifications + a(href="", tg-nav="user-settings-web-notifications", title="{{ 'USER_SETTINGS.MENU.EVENTS' | translate }}") + span.title(translate="USER_SETTINGS.MENU.EVENTS") li#usersettings-contrib(ng-repeat="plugin in userSettingsPlugins") a( href="" diff --git a/app/partials/includes/modules/user-settings/web-notifications-table.jade b/app/partials/includes/modules/user-settings/web-notifications-table.jade new file mode 100644 index 00000000..271f7fb1 --- /dev/null +++ b/app/partials/includes/modules/user-settings/web-notifications-table.jade @@ -0,0 +1,8 @@ +section.policy-table + div.policy-table-header + div.policy-table-row + div.policy-table-project + span(translate="USER_SETTINGS.EVENTS.COLUMN_PROJECT") + div.policy-table-enabled + span(translate="USER_SETTINGS.EVENTS.COLUMN_ENABLED") + div.policy-table-body(tg-user-web-notifications-list, ng-model="notifyPolicies") diff --git a/app/partials/user/web-notifications.jade b/app/partials/user/web-notifications.jade new file mode 100644 index 00000000..141d71e8 --- /dev/null +++ b/app/partials/user/web-notifications.jade @@ -0,0 +1,24 @@ +doctype html + +div.wrapper( + tg-user-web-notifications + ng-controller="UserWebNotificationsController as ctrl", + ng-init="section='web-notifications'" +) + + sidebar.menu-secondary.sidebar.settings-nav(tg-user-settings-navigation="web-notifications") + include ../includes/modules/user-settings-menu + + section.main.admin-common + header + h1 + span.green + | {{sectionName | translate}} + tg-svg.green(svg-icon="icon-bell") + + p.total + span(translate="USER_SETTINGS.EVENTS.SECTION_DESCRIPTION") + |   + small(translate="USER_SETTINGS.EVENTS.SECTION_DESCRIPTION_EXPANDED") + + include ../includes/modules/user-settings/web-notifications-table diff --git a/app/styles/core/typography.scss b/app/styles/core/typography.scss index 6b8469d2..c798443c 100755 --- a/app/styles/core/typography.scss +++ b/app/styles/core/typography.scss @@ -68,6 +68,7 @@ h1 { .green { color: $primary; + fill: $primary; } .date { diff --git a/app/styles/modules/user-settings/mail-notifications-table.scss b/app/styles/modules/user-settings/mail-notifications-table.scss index 5e547d6d..8c20b443 100644 --- a/app/styles/modules/user-settings/mail-notifications-table.scss +++ b/app/styles/modules/user-settings/mail-notifications-table.scss @@ -11,10 +11,11 @@ border-bottom: 2px solid $gray-light; } - .policy-table-project , + .policy-table-project, .policy-table-all, .policy-table-involved, - .policy-table-none { + .policy-table-none, + .policy-table-enabled { padding: 1rem; } @@ -25,7 +26,8 @@ .policy-table-all, .policy-table-involved, - .policy-table-none { + .policy-table-none, + .policy-table-enabled { flex-basis: 0; flex-grow: 1; } diff --git a/app/svg/sprite.svg b/app/svg/sprite.svg index e925ea21..bcfe2693 100644 --- a/app/svg/sprite.svg +++ b/app/svg/sprite.svg @@ -1,5 +1,13 @@ + + bell + + + + + iocaine