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 @@