Notifications module

stable
Daniel García 2018-09-24 15:42:03 +02:00 committed by Alex Hermida
parent 4115b3bb93
commit 529c91d9e9
29 changed files with 975 additions and 11 deletions

View File

@ -465,6 +465,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
{templateUrl: "user/mail-notifications.html"}) {templateUrl: "user/mail-notifications.html"})
$routeProvider.when("/user-settings/live-notifications", $routeProvider.when("/user-settings/live-notifications",
{templateUrl: "user/live-notifications.html"}) {templateUrl: "user/live-notifications.html"})
$routeProvider.when("/user-settings/web-notifications",
{templateUrl: "user/web-notifications.html"})
$routeProvider.when("/change-email/:email_token", $routeProvider.when("/change-email/:email_token",
{templateUrl: "user/change-email.html"}) {templateUrl: "user/change-email.html"})
$routeProvider.when("/cancel-account/:cancel_token", $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", $routeProvider.when("/profile/:slug",
{ {
templateUrl: "profile/profile.html", templateUrl: "profile/profile.html",
@ -937,6 +952,7 @@ modules = [
"taigaExternalApps", "taigaExternalApps",
"taigaDiscover", "taigaDiscover",
"taigaHistory", "taigaHistory",
"taigaNotifications",
"taigaWikiHistory", "taigaWikiHistory",
"taigaEpics", "taigaEpics",
"taigaUtils" "taigaUtils"

View File

@ -123,8 +123,10 @@ urls = {
"user-settings-user-project-settings": "/user-settings/user-project-settings" "user-settings-user-project-settings": "/user-settings/user-project-settings"
"user-settings-mail-notifications": "/user-settings/mail-notifications" "user-settings-mail-notifications": "/user-settings/mail-notifications"
"user-settings-live-notifications": "/user-settings/live-notifications" "user-settings-live-notifications": "/user-settings/live-notifications"
"user-settings-web-notifications": "/user-settings/web-notifications"
"user-settings-contrib": "/user-settings/contrib/:plugin" "user-settings-contrib": "/user-settings/contrib/:plugin"
"notifications": "/notifications"
} }
init = ($log, $navurls) -> init = ($log, $navurls) ->

View File

@ -46,6 +46,7 @@ urls = {
# User - Notification # User - Notification
"permissions": "/permissions" "permissions": "/permissions"
"notify-policies": "/notify-policies" "notify-policies": "/notify-policies"
"notifications": "/web-notifications"
# User Project Settings # User Project Settings
"user-project-settings": "/user-project-settings" "user-project-settings": "/user-project-settings"

View File

@ -44,7 +44,7 @@ class UserLiveNotificationsController extends mixOf(taiga.Controller, taiga.Page
] ]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @navUrls, @auth, @errorHandlingService) -> 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() @scope.user = @auth.getUser()
promise = @.loadInitialData() promise = @.loadInitialData()
promise.then null, @.onInitialDataError.bind(@) promise.then null, @.onInitialDataError.bind(@)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# 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) { %>
<div class="policy-table-row">
<div class="policy-table-project"><span><%- notifyPolicy.project_name %></span></div>
<div class="policy-table-all">
<div class="check" data-index="<%- index %>">
<input type="checkbox"
<% if(notifyPolicy.web_notify_level) { %> checked="checked" <% } %>
name="policy-<%- notifyPolicy.id %>" id="policy-<%- notifyPolicy.id %>"/>
<div></div>
<span class="check-text check-yes" translate="COMMON.YES"></span>
<span class="check-text check-no"" translate="COMMON.NO"></span>
</div>
</div>
</div>
<% }) %>
""")
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])

View File

@ -1550,7 +1550,8 @@
"USER_PROFILE": "User profile", "USER_PROFILE": "User profile",
"CHANGE_PASSWORD": "Change password", "CHANGE_PASSWORD": "Change password",
"EMAIL_NOTIFICATIONS": "Email notifications", "EMAIL_NOTIFICATIONS": "Email notifications",
"DESKTOP_NOTIFICATIONS": "Desktop notifications" "DESKTOP_NOTIFICATIONS": "Desktop notifications",
"EVENTS": "Events"
}, },
"NOTIFICATIONS": { "NOTIFICATIONS": {
"LIVE_SECTION_NAME": "Desktop Notifications", "LIVE_SECTION_NAME": "Desktop Notifications",
@ -1569,6 +1570,13 @@
"COLUMN_PROJECT": "Project", "COLUMN_PROJECT": "Project",
"COLUMN_STARTPAGE": "Start page", "COLUMN_STARTPAGE": "Start page",
"DEFAULT_VALUE": "Default" "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": { "USER_PROFILE": {
@ -1666,7 +1674,25 @@
"US_REMOVED_FROM_MILESTONE": "{{username}} has added the US {{obj_name}} to the backlog", "US_REMOVED_FROM_MILESTONE": "{{username}} has added the US {{obj_name}} to the backlog",
"BLOCKED": "{{username}} has blocked {{obj_name}}", "BLOCKED": "{{username}} has blocked {{obj_name}}",
"UNBLOCKED": "{{username}} has unblocked {{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": { "LEGAL": {
"TERMS_OF_SERVICE_AND_PRIVACY_POLICY_AD": "<span>When creating a new account, you agree to our </span><a href=\"{{ termsOfServiceUrl }}\" title=\"See terms of service\" target=\"_blank\">terms of service</a><span> and </span><a href=\"{{ privacyPolicyUrl }}\" title=\"See privacy policy\" target=\"_blank\">privacy policy</a>.", "TERMS_OF_SERVICE_AND_PRIVACY_POLICY_AD": "<span>When creating a new account, you agree to our </span><a href=\"{{ termsOfServiceUrl }}\" title=\"See terms of service\" target=\"_blank\">terms of service</a><span> and </span><a href=\"{{ privacyPolicyUrl }}\" title=\"See privacy policy\" target=\"_blank\">privacy policy</a>.",

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# 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])

View File

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

View File

@ -54,4 +54,9 @@ nav.navbar(ng-if="vm.isEnabledHeader")
tg-svg(svg-icon="icon-discover") tg-svg(svg-icon="icon-discover")
div.topnav-dropdown-wrapper(ng-show="vm.projects.size", tg-dropdown-project-list) 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) div.topnav-dropdown-wrapper(tg-dropdown-user)

View File

@ -37,7 +37,8 @@ $dropdown-width: 350px;
} }
.nav-right { .nav-right {
margin-left: auto; margin-left: auto;
a { > a,
.topnav-dropdown-wrapper > a {
color: $white; color: $white;
padding: .5rem 2rem; padding: .5rem 2rem;
} }
@ -47,7 +48,8 @@ $dropdown-width: 350px;
transition: all .2s linear; transition: all .2s linear;
} }
} }
a { > a,
.topnav-dropdown-wrapper > a {
color: $white; color: $white;
display: inline-block; display: inline-block;
transition: all .2s linear; transition: all .2s linear;
@ -70,7 +72,7 @@ $dropdown-width: 350px;
} }
} }
img { .user-avatar img {
height: 2.5rem; height: 2.5rem;
margin-left: .5rem; margin-left: .5rem;
vertical-align: middle; vertical-align: middle;
@ -169,3 +171,106 @@ $dropdown-width: 350px;
flex-direction: row; 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;
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# 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)

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# 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)

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# File: projects/projects.module.coffee
###
angular.module("taigaNotifications", [])

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# 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 = $('<span>')
.attr('ng-non-bindable', true)
.text(text)
return $('<a href="">')
.attr('title', title)
.attr('class', css)
.attr('ng-click', "vm.setAsRead(notification, \"#{url}\")")
.append(span)
.prop('outerHTML')
_getUsernameSpan: (text) ->
title = title || text
return $('<span>')
.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)

View File

@ -150,6 +150,41 @@ Resource = (urlsService, http, paginateResponseService) ->
result = Immutable.fromJS(result) result = Immutable.fromJS(result)
return paginateResponseService(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 () ->
return {"users": service} return {"users": service}

View File

@ -47,11 +47,14 @@ UserTimelinePaginationSequence = () ->
if items.size < config.minItems && response.get("next") if items.size < config.minItems && response.get("next")
return getContent() return getContent()
return Immutable.Map({ pagination = Immutable.Map({
items: items, items: items,
total: response.get("total"),
next: response.get("next") next: response.get("next")
}) })
return pagination
return { return {
next: () -> next() next: () -> next()
} }

View File

@ -1,5 +1,5 @@
section.profile-timeline section.profile-timeline
div(ng-if="!vm.timelineList.size") div(ng-if="vm.loading")
div.spin div.spin
img( img(
src="/#{v}/svg/spinner-circle.svg" src="/#{v}/svg/spinner-circle.svg"

View File

@ -16,6 +16,9 @@ section.admin-menu
li#usersettingsmenu-live-notifications li#usersettingsmenu-live-notifications
a(href="", tg-nav="user-settings-live-notifications", title="{{ 'USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS' | translate }}") a(href="", tg-nav="user-settings-live-notifications", title="{{ 'USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS' | translate }}")
span.title(translate="USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS") 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") li#usersettings-contrib(ng-repeat="plugin in userSettingsPlugins")
a( a(
href="" href=""

View File

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

View File

@ -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")
| &nbsp;
small(translate="USER_SETTINGS.EVENTS.SECTION_DESCRIPTION_EXPANDED")
include ../includes/modules/user-settings/web-notifications-table

View File

@ -68,6 +68,7 @@ h1 {
.green { .green {
color: $primary; color: $primary;
fill: $primary;
} }
.date { .date {

View File

@ -14,7 +14,8 @@
.policy-table-project, .policy-table-project,
.policy-table-all, .policy-table-all,
.policy-table-involved, .policy-table-involved,
.policy-table-none { .policy-table-none,
.policy-table-enabled {
padding: 1rem; padding: 1rem;
} }
@ -25,7 +26,8 @@
.policy-table-all, .policy-table-all,
.policy-table-involved, .policy-table-involved,
.policy-table-none { .policy-table-none,
.policy-table-enabled {
flex-basis: 0; flex-basis: 0;
flex-grow: 1; flex-grow: 1;
} }

View File

@ -1,5 +1,13 @@
<svg height="0" style="position: absolute; width: 0; height: 0;" version="1.1" width="0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <svg height="0" style="position: absolute; width: 0; height: 0;" version="1.1" width="0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<symbol id="icon-bell" viewBox="0 0 100 100">
<title>bell</title>
<g transform="translate(0,-191.16665)">
<path style="fill-opacity:1;stroke-width:7.27812004;"
d="m 52.916943,191.16665 c -7.112236,0 -13.04271,5.15386 -14.29523,11.91257 -18.460239,8.56605 -31.7004628,32.57982 -31.7890308,54.8856 l -0.01114,13.1094 H 99.011791 l -0.0232,-13.1094 c -0.08417,-22.30704 -13.315506,-46.3206 -31.778082,-54.8856 -1.252625,-6.75871 -7.182994,-11.91257 -14.295293,-11.91257 z M 38.360599,279.8647 v 1.15368 c 0.03368,8.75953 6.489039,15.97886 14.556344,15.9816 8.067957,-10e-4 14.503375,-7.22165 14.54548,-15.9816 l 0.01116,-1.15368 z" id="path10199" inkscape:connector-curvature="0">
</path>
</g>
</symbol>
<symbol id="icon-iocaine" viewBox="0 0 1024 1024"> <symbol id="icon-iocaine" viewBox="0 0 1024 1024">
<title>iocaine</title> <title>iocaine</title>
<path <path

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -14,6 +14,7 @@ $white: #fff;
// Primary colors // Primary colors
$primary-light: #212121; $primary-light: #212121;
$primary-lighter: #E0E0E0;
$primary: #000; $primary: #000;
$primary-dark: #000; $primary-dark: #000;
$primary-background: #dfdfdf; $primary-background: #dfdfdf;

View File

@ -17,6 +17,7 @@ $mass-white: #f5f5f5;
// Primary colors // Primary colors
$primary-light: #8c9eff; $primary-light: #8c9eff;
$primary-lighter: #ECEDFB;
$primary: #3f51b5; $primary: #3f51b5;
$primary-dark: #1a237e; $primary-dark: #1a237e;
$primary-background: #929dd8; $primary-background: #929dd8;

View File

@ -17,6 +17,7 @@ $mass-white: #f5f5f5;
// Primary colors // Primary colors
$primary-light: #9dce0a; $primary-light: #9dce0a;
$primary-lighter: #EFF3E3;
$primary: #5b8200; $primary: #5b8200;
$primary-dark: #879b89; $primary-dark: #879b89;
$primary-background: #E8F5E3; $primary-background: #E8F5E3;