Merge pull request #1142 from metamaps/feature/notifs.box

Notifications Dropdown
This commit is contained in:
Connor Turland 2017-10-13 12:29:21 -04:00 committed by GitHub
commit 55f2425501
17 changed files with 646 additions and 128 deletions

View file

@ -1,4 +1,7 @@
$notifications-border-color: #DDDDDD;
$notifications-hover-color: #F6F6F6;
$unread_notifications_dot_size: 8px; $unread_notifications_dot_size: 8px;
.unread-notifications-dot { .unread-notifications-dot {
width: $unread_notifications_dot_size; width: $unread_notifications_dot_size;
height: $unread_notifications_dot_size; height: $unread_notifications_dot_size;
@ -13,13 +16,72 @@ $unread_notifications_dot_size: 8px;
.notificationsIcon { .notificationsIcon {
position: relative; position: relative;
} }
.notificationsBox {
position: absolute;
background: #FFFFFF;
border-radius: 2px;
width: 350px;
right: 0;
top: 50px;
box-shadow: 0 3px 6px rgba(0,0,0,0.16);
border: 1px solid $notifications-border-color;
.notificationsBoxTriangle {
min-width: 0 !important;
display: block;
position: absolute;
right: 48px;
width: 20px !important;
height: 20px !important;
margin-left: -10px;
top: -11px;
border-left: 1px solid $notifications-border-color;
border-top: 1px solid $notifications-border-color;
border-bottom: 0 !important;
border-right: 0 !important;
background-color: #fff;
transform: rotate(45deg);
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
}
ul.notifications {
max-height: 500px;
overflow-y: auto;
.notification {
font-size: 13px;
.notification-body {
border-bottom: 1px solid $notifications-border-color;
}
}
.notificationsEmpty {
font-family: din-regular, helvetica, sans-serif;
margin: 50px 10px;
text-align: center;
}
}
.notificationsBoxSeeAll {
display: block;
width: 100%;
text-align: center;
padding: 6px 0;
font-family: din-regular, helvetica, sans-serif;
border-top: 1px solid rgba(0, 0, 0, 0.1);
&:hover {
color: #333;
background: $notifications-hover-color;
}
}
}
} }
.controller-notifications { .controller-notifications {
ul.notifications {
list-style: none;
}
.notificationPage, .notificationPage,
.notificationsPage { .notificationsPage {
font-family: 'din-regular', Sans-Serif; font-family: 'din-regular', Sans-Serif;
@ -47,89 +109,9 @@ $unread_notifications_dot_size: 8px;
.emptyInbox { .emptyInbox {
padding-top: 15px; padding-top: 15px;
} }
.notification {
padding: 10px;
position: relative;
&:hover {
background: #F6F6F6;
.notification-read-unread {
display:block;
}
.notification-date {
display: none;
}
}
& > a {
float: left;
width: 85%;
box-sizing: border-box;
padding-right: 10px;
}
.notification-actor {
float: left;
img {
width: 32px;
height: 32px;
border-radius: 16px;
}
}
.notification-body {
margin-left: 50px;
line-height: 20px;
.in-bold {
font-family: 'din-medium', Sans-Serif;
}
.action {
background: #4fb5c0;
color: #FFF;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
margin: 5px 0;
}
}
.notification-date {
position: absolute;
top: 50%;
right: 10px;
color: #607d8b;
font-size: 13px;
line-height: 13px;
margin-top: -6px;
}
.notification-read-unread {
display: none;
float: left;
width: 15%;
a {
position: absolute;
top: 50%;
margin-top: -10px;
text-align: center;
}
}
&.unread {
background: #EEE;
}
}
} }
.notificationPage { .notificationPage {
.thirty-two-avatar { .thirty-two-avatar {
@ -139,14 +121,14 @@ $unread_notifications_dot_size: 8px;
border-radius: 16px; border-radius: 16px;
vertical-align: middle; vertical-align: middle;
} }
.button { .button {
line-height: 32px; line-height: 32px;
img { img {
margin-top: 8px; margin-top: 8px;
} }
&.decline { &.decline {
background: #DB5D5D; background: #DB5D5D;
&:hover { &:hover {
@ -154,7 +136,7 @@ $unread_notifications_dot_size: 8px;
} }
} }
} }
.notification-body { .notification-body {
p, div { p, div {
margin: 1em auto; margin: 1em auto;
@ -163,3 +145,93 @@ $unread_notifications_dot_size: 8px;
} }
} }
} }
ul.notifications {
list-style: none;
li:nth-last-child(2) {
.notification-body {
border-bottom: none !important;
}
}
}
.notification {
padding: 10px 10px 0 10px;
position: relative;
font-family: 'din-regular', Sans-Serif;
&.unread {
background: #EEE;
}
&:hover {
background: $notifications-hover-color;
.notification-read-unread {
display:block;
}
.notification-date {
display: none;
}
}
& > a {
float: left;
width: 85%;
box-sizing: border-box;
padding-right: 10px;
}
.notification-actor {
float: left;
img {
width: 32px;
height: 32px;
border-radius: 16px;
}
}
.notification-body {
margin-left: 50px;
line-height: 20px;
padding-bottom: 10px;
.in-bold {
font-family: 'din-medium', Sans-Serif;
}
.action {
background: #4fb5c0;
color: #FFF;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
margin: 5px 0;
}
}
.notification-date {
position: absolute;
top: 50%;
right: 10px;
color: #607d8b;
margin-top: -6px;
}
.notification-read-unread {
display: none;
float: left;
width: 15%;
a, div {
position: absolute;
top: 50%;
margin-top: -10px;
text-align: center;
cursor: pointer;
}
}
}

View file

@ -6,13 +6,17 @@ class NotificationsController < ApplicationController
def index def index
@notifications = current_user.mailbox.notifications.page(params[:page]).per(25) @notifications = current_user.mailbox.notifications.page(params[:page]).per(25)
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: @notifications.map do |notification| notifications = @notifications.map do |notification|
receipt = @receipts.find_by(notification_id: notification.id) receipt = @receipts.find_by(notification_id: notification.id)
notification.as_json.merge(is_read: receipt.is_read) NotificationDecorator.decorate(notification, receipt)
end
if !notifications.empty?
render json: notifications
else
render json: [].to_json
end end
end end
end end
@ -34,9 +38,7 @@ class NotificationsController < ApplicationController
end end
end end
format.json do format.json do
render json: @notification.as_json.merge( render json: NotificationDecorator.decorate(@notification, @receipt)
is_read: @receipt.is_read
)
end end
end end
end end
@ -46,9 +48,7 @@ class NotificationsController < ApplicationController
respond_to do |format| respond_to do |format|
format.js format.js
format.json do format.json do
render json: @notification.as_json.merge( render json: NotificationDecorator.decorate(@notification, @receipt)
is_read: @receipt.is_read
)
end end
end end
end end
@ -58,9 +58,7 @@ class NotificationsController < ApplicationController
respond_to do |format| respond_to do |format|
format.js format.js
format.json do format.json do
render json: @notification.as_json.merge( render json: NotificationDecorator.decorate(@notification, @receipt)
is_read: @receipt.is_read
)
end end
end end
end end

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
class NotificationDecorator
class << self
def decorate(notification, receipt)
result = {
id: notification.id,
type: notification.notification_code,
subject: notification.subject,
is_read: receipt.is_read,
created_at: notification.created_at,
actor: notification.sender,
data: {
object: notification.notified_object
}
}
case notification.notification_code
when MAP_ACCESS_APPROVED, MAP_ACCESS_REQUEST, MAP_INVITE_TO_EDIT
map = notification.notified_object&.map
result[:data][:map] = {
id: map&.id,
name: map&.name
}
when TOPIC_ADDED_TO_MAP
topic = notification.notified_object&.eventable
map = notification.notified_object&.map
result[:data][:topic] = {
id: topic&.id,
name: topic&.name
}
result[:data][:map] = {
id: map&.id,
name: map&.name
}
when TOPIC_CONNECTED_1, TOPIC_CONNECTED_2
topic1 = notification.notified_object&.topic1
topic2 = notification.notified_object&.topic2
result[:data][:topic1] = {
id: topic1&.id,
name: topic1&.name
}
result[:data][:topic2] = {
id: topic2&.id,
name: topic2&.name
}
end
result
end
end
end

View file

@ -8,7 +8,7 @@
</header> </header>
<ul class="notifications"> <ul class="notifications">
<% blacklist = [MAP_ACCESS_REQUEST, MAP_ACCESS_APPROVED, MAP_INVITE_TO_EDIT] %> <% blacklist = [MAP_ACCESS_REQUEST, MAP_ACCESS_APPROVED, MAP_INVITE_TO_EDIT] %>
<% notifications = @notifications.to_a.delete_if{|n| blacklist.include?(n.notification_code) && (n.notified_object.nil? || n.notified_object.map.nil?) }%> <% notifications = @notifications.to_a.delete_if{|n| blacklist.include?(n.notification_code) && (n.notified_object.nil? || n.notified_object.map.nil?) }%>
<% notifications.each do |notification| %> <% notifications.each do |notification| %>
<% receipt = @receipts.find_by(notification_id: notification.id) %> <% receipt = @receipts.find_by(notification_id: notification.id) %>
<li class="notification <%= receipt.is_read? ? 'read' : 'unread' %>" id="notification-<%= notification.id %>"> <li class="notification <%= receipt.is_read? ? 'read' : 'unread' %>" id="notification-<%= notification.id %>">

View file

@ -4,4 +4,4 @@ $('#notification-<%= @notification.id %> .notification-read-unread > a')
$('#notification-<%= @notification.id %>') $('#notification-<%= @notification.id %>')
.removeClass('unread') .removeClass('unread')
.addClass('read') .addClass('read')
Metamaps.GlobalUI.NotificationIcon.render(Metamaps.GlobalUI.NotificationIcon.unreadNotificationsCount - 1) Metamaps.GlobalUI.Notifications.decrementUnread(Metamaps.GlobalUI.ReactApp.render)

View file

@ -4,4 +4,4 @@ $('#notification-<%= @notification.id %> .notification-read-unread > a')
$('#notification-<%= @notification.id %>') $('#notification-<%= @notification.id %>')
.removeClass('read') .removeClass('read')
.addClass('unread') .addClass('unread')
Metamaps.GlobalUI.NotificationIcon.render(Metamaps.GlobalUI.NotificationIcon.unreadNotificationsCount + 1) Metamaps.GlobalUI.Notifications.incrementUnread(Metamaps.GlobalUI.ReactApp.render)

View file

@ -19,7 +19,7 @@ module Metamaps
end end
# Custom directories with classes and modules you want to be autoloadable. # Custom directories with classes and modules you want to be autoloadable.
config.autoload_paths << Rails.root.join('app', 'services') config.autoload_paths << Rails.root.join('app', 'decorators', 'services')
# Configure the default encoding used in templates for Ruby 1.9. # Configure the default encoding used in templates for Ruby 1.9.
config.encoding = 'utf-8' config.encoding = 'utf-8'

View file

@ -0,0 +1,63 @@
/* global $ */
import GlobalUI from './index'
const Notifications = {
notifications: null,
unreadNotificationsCount: 0,
init: serverData => {
Notifications.unreadNotificationsCount = serverData.unreadNotificationsCount
},
fetch: render => {
$.ajax({
url: '/notifications.json',
success: function(data) {
Notifications.notifications = data
render()
}
})
},
incrementUnread: (render) => {
Notifications.unreadNotificationsCount++
render()
},
decrementUnread: (render) => {
Notifications.unreadNotificationsCount--
render()
},
markAsRead: (render, id) => {
const n = Notifications.notifications.find(n => n.id === id)
$.ajax({
url: `/notifications/${id}/mark_read.json`,
method: 'PUT',
success: function(r) {
if (n) {
Notifications.unreadNotificationsCount--
n.is_read = true
render()
}
},
error: function() {
GlobalUI.notifyUser('There was an error marking that notification as read')
}
})
},
markAsUnread: (render, id) => {
const n = Notifications.notifications.find(n => n.id === id)
$.ajax({
url: `/notifications/${id}/mark_unread.json`,
method: 'PUT',
success: function() {
if (n) {
Notifications.unreadNotificationsCount++
n.is_read = false
render()
}
},
error: function() {
GlobalUI.notifyUser('There was an error marking that notification as read')
}
})
}
}
export default Notifications

View file

@ -8,6 +8,7 @@ import apply from 'async/apply'
import { notifyUser } from './index.js' import { notifyUser } from './index.js'
import ImportDialog from './ImportDialog' import ImportDialog from './ImportDialog'
import Notifications from './Notifications'
import Active from '../Active' import Active from '../Active'
import DataModel from '../DataModel' import DataModel from '../DataModel'
import { ExploreMaps, ChatView, TopicCard, ContextMenu } from '../Views' import { ExploreMaps, ChatView, TopicCard, ContextMenu } from '../Views'
@ -30,7 +31,6 @@ const ReactApp = {
serverData: {}, serverData: {},
mapId: null, mapId: null,
topicId: null, topicId: null,
unreadNotificationsCount: 0,
mapsWidth: 0, mapsWidth: 0,
toast: '', toast: '',
mobile: false, mobile: false,
@ -40,7 +40,6 @@ const ReactApp = {
init: function(serverData, openLightbox) { init: function(serverData, openLightbox) {
const self = ReactApp const self = ReactApp
self.serverData = serverData self.serverData = serverData
self.unreadNotificationsCount = serverData.unreadNotificationsCount
self.mobileTitle = serverData.mobileTitle self.mobileTitle = serverData.mobileTitle
self.openLightbox = openLightbox self.openLightbox = openLightbox
self.metacodeSets = serverData.metacodeSets self.metacodeSets = serverData.metacodeSets
@ -99,7 +98,7 @@ const ReactApp = {
getProps: function() { getProps: function() {
const self = ReactApp const self = ReactApp
return merge({ return merge({
unreadNotificationsCount: self.unreadNotificationsCount, unreadNotificationsCount: Notifications.unreadNotificationsCount,
currentUser: Active.Mapper, currentUser: Active.Mapper,
toast: self.toast, toast: self.toast,
mobile: self.mobile, mobile: self.mobile,
@ -107,7 +106,11 @@ const ReactApp = {
mobileTitleWidth: self.mobileTitleWidth, mobileTitleWidth: self.mobileTitleWidth,
mobileTitleClick: (e) => Active.Map && InfoBox.toggleBox(e), mobileTitleClick: (e) => Active.Map && InfoBox.toggleBox(e),
openInviteLightbox: () => self.openLightbox('invite'), openInviteLightbox: () => self.openLightbox('invite'),
serverData: self.serverData serverData: self.serverData,
notifications: Notifications.notifications,
fetchNotifications: apply(Notifications.fetch, ReactApp.render),
markAsRead: apply(Notifications.markAsRead, ReactApp.render),
markAsUnread: apply(Notifications.markAsUnread, ReactApp.render)
}, },
self.getMapProps(), self.getMapProps(),
self.getTopicProps(), self.getTopicProps(),

View file

@ -4,6 +4,7 @@ import clipboard from 'clipboard-js'
import Create from '../Create' import Create from '../Create'
import Notifications from './Notifications'
import ReactApp from './ReactApp' import ReactApp from './ReactApp'
import Search from './Search' import Search from './Search'
import CreateMap from './CreateMap' import CreateMap from './CreateMap'
@ -17,6 +18,7 @@ const GlobalUI = {
init: function(serverData) { init: function(serverData) {
const self = GlobalUI const self = GlobalUI
self.Notifications.init(serverData)
self.ReactApp.init(serverData, self.openLightbox) self.ReactApp.init(serverData, self.openLightbox)
self.CreateMap.init(serverData) self.CreateMap.init(serverData)
self.ImportDialog.init(serverData, self.openLightbox, self.closeLightbox) self.ImportDialog.init(serverData, self.openLightbox, self.closeLightbox)
@ -151,5 +153,5 @@ const GlobalUI = {
} }
} }
export { ReactApp, Search, CreateMap, ImportDialog } export { Notifications, ReactApp, Search, CreateMap, ImportDialog }
export default GlobalUI export default GlobalUI

View file

@ -9,7 +9,7 @@ import DataModel from './DataModel'
import Debug from './Debug' import Debug from './Debug'
import Filter from './Filter' import Filter from './Filter'
import GlobalUI, { import GlobalUI, {
ReactApp, Search, CreateMap, ImportDialog Notifications, ReactApp, Search, CreateMap, ImportDialog
} from './GlobalUI' } from './GlobalUI'
import Import from './Import' import Import from './Import'
import JIT from './JIT' import JIT from './JIT'
@ -42,6 +42,7 @@ Metamaps.DataModel = DataModel
Metamaps.Debug = Debug Metamaps.Debug = Debug
Metamaps.Filter = Filter Metamaps.Filter = Filter
Metamaps.GlobalUI = GlobalUI Metamaps.GlobalUI = GlobalUI
Metamaps.GlobalUI.Notifications = Notifications
Metamaps.GlobalUI.ReactApp = ReactApp Metamaps.GlobalUI.ReactApp = ReactApp
Metamaps.GlobalUI.Search = Search Metamaps.GlobalUI.Search = Search
Metamaps.GlobalUI.CreateMap = CreateMap Metamaps.GlobalUI.CreateMap = CreateMap

View file

@ -0,0 +1,74 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import onClickOutsideAddon from 'react-onclickoutside'
import Notification from '../Notification'
import Loading from '../Loading'
class NotificationBox extends Component {
static propTypes = {
notifications: PropTypes.array,
fetchNotifications: PropTypes.func.isRequired,
toggleNotificationsBox: PropTypes.func.isRequired,
markAsRead: PropTypes.func.isRequired,
markAsUnread: PropTypes.func.isRequired
}
componentDidMount = () => {
const { notifications, fetchNotifications } = this.props
if (!notifications) {
fetchNotifications()
}
}
handleClickOutside = () => {
this.props.toggleNotificationsBox()
}
hasSomeNotifications = () => {
const { notifications } = this.props
return notifications && notifications.length > 0
}
showLoading = () => {
return <li><Loading margin='30px auto' /></li>
}
showEmpty = () => {
return <li className='notificationsEmpty'>
You have no notifications. <br />
More time for dancing.
</li>
}
showNotifications = () => {
const { notifications, markAsRead, markAsUnread } = this.props
if (!this.hasSomeNotifications()) {
return this.showEmpty()
}
return notifications.slice(0, 10).map(
n => <Notification notification={n}
markAsRead={markAsRead}
markAsUnread={markAsUnread}
key={`notification-${n.id}`} />
).concat([
<li key='notification-see-all'>
<a href='/notifications' className='notificationsBoxSeeAll'>
See all
</a>
</li>
])
}
render = () => {
const { notifications } = this.props
return <div className='notificationsBox'>
<div className='notificationsBoxTriangle' />
<ul className='notifications'>
{notifications ? this.showNotifications() : this.showLoading()}
</ul>
</div>
}
}
export default onClickOutsideAddon(NotificationBox)

View file

@ -4,18 +4,14 @@ import PropTypes from 'prop-types'
class NotificationIcon extends Component { class NotificationIcon extends Component {
static propTypes = { static propTypes = {
unreadNotificationsCount: PropTypes.number unreadNotificationsCount: PropTypes.number,
} toggleNotificationsBox: PropTypes.func
constructor(props) {
super(props)
this.state = {
}
} }
render = () => { render = () => {
const { toggleNotificationsBox } = this.props
let linkClasses = 'notificationsIcon upperRightEl upperRightIcon ' let linkClasses = 'notificationsIcon upperRightEl upperRightIcon '
linkClasses += 'ignore-react-onclickoutside '
if (this.props.unreadNotificationsCount > 0) { if (this.props.unreadNotificationsCount > 0) {
linkClasses += 'unread' linkClasses += 'unread'
@ -24,14 +20,14 @@ class NotificationIcon extends Component {
} }
return ( return (
<a className={linkClasses} href="/notifications" target="_blank"> <div className={linkClasses} onClick={toggleNotificationsBox}>
<div className="tooltipsUnder"> <div className="tooltipsUnder">
Notifications Notifications
</div> </div>
{this.props.unreadNotificationsCount === 0 ? null : ( {this.props.unreadNotificationsCount === 0 ? null : (
<div className="unread-notifications-dot"></div> <div className="unread-notifications-dot"></div>
)} )}
</a> </div>
) )
} }

View file

@ -4,31 +4,54 @@ import PropTypes from 'prop-types'
import AccountMenu from './AccountMenu' import AccountMenu from './AccountMenu'
import LoginForm from './LoginForm' import LoginForm from './LoginForm'
import NotificationIcon from './NotificationIcon' import NotificationIcon from './NotificationIcon'
import NotificationBox from './NotificationBox'
class UpperRightUI extends Component { class UpperRightUI extends Component {
static propTypes = { static propTypes = {
currentUser: PropTypes.object, currentUser: PropTypes.object,
signInPage: PropTypes.bool, signInPage: PropTypes.bool,
unreadNotificationsCount: PropTypes.number, unreadNotificationsCount: PropTypes.number,
fetchNotifications: PropTypes.func,
notifications: PropTypes.array,
markAsRead: PropTypes.func.isRequired,
markAsUnread: PropTypes.func.isRequired,
openInviteLightbox: PropTypes.func openInviteLightbox: PropTypes.func
} }
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {accountBoxOpen: false} this.state = {
accountBoxOpen: false,
notificationsBoxOpen: false
}
} }
reset = () => { reset = () => {
this.setState({accountBoxOpen: false}) this.setState({
accountBoxOpen: false,
notificationsBoxOpen: false
})
} }
toggleAccountBox = () => { toggleAccountBox = () => {
this.setState({accountBoxOpen: !this.state.accountBoxOpen}) this.setState({
accountBoxOpen: !this.state.accountBoxOpen,
notificationsBoxOpen: false
})
}
toggleNotificationsBox = () => {
this.setState({
notificationsBoxOpen: !this.state.notificationsBoxOpen,
accountBoxOpen: false
})
} }
render () { render () {
const { currentUser, signInPage, unreadNotificationsCount, openInviteLightbox } = this.props const { currentUser, signInPage, unreadNotificationsCount,
const { accountBoxOpen } = this.state notifications, fetchNotifications, openInviteLightbox,
markAsRead, markAsUnread } = this.props
const { accountBoxOpen, notificationsBoxOpen } = this.state
return <div className="upperRightUI"> return <div className="upperRightUI">
{currentUser && <a href="/maps/new" target="_blank" className="addMap upperRightEl upperRightIcon"> {currentUser && <a href="/maps/new" target="_blank" className="addMap upperRightEl upperRightIcon">
<div className="tooltipsUnder"> <div className="tooltipsUnder">
@ -36,7 +59,15 @@ class UpperRightUI extends Component {
</div> </div>
</a>} </a>}
{currentUser && <span id="notification_icon"> {currentUser && <span id="notification_icon">
<NotificationIcon unreadNotificationsCount={unreadNotificationsCount} /> <NotificationIcon
unreadNotificationsCount={unreadNotificationsCount}
toggleNotificationsBox={this.toggleNotificationsBox}/>
{notificationsBoxOpen && <NotificationBox
notifications={notifications}
fetchNotifications={fetchNotifications}
markAsRead={markAsRead}
markAsUnread={markAsUnread}
toggleNotificationsBox={this.toggleNotificationsBox}/>}
</span>} </span>}
{!signInPage && <div className="sidebarAccount upperRightEl"> {!signInPage && <div className="sidebarAccount upperRightEl">
<div className="sidebarAccountIcon ignore-react-onclickoutside" onClick={this.toggleAccountBox}> <div className="sidebarAccountIcon ignore-react-onclickoutside" onClick={this.toggleAccountBox}>

View file

@ -11,6 +11,10 @@ class App extends Component {
children: PropTypes.object, children: PropTypes.object,
toast: PropTypes.string, toast: PropTypes.string,
unreadNotificationsCount: PropTypes.number, unreadNotificationsCount: PropTypes.number,
notifications: PropTypes.array,
fetchNotifications: PropTypes.func,
markAsRead: PropTypes.func,
markAsUnread: PropTypes.func,
location: PropTypes.object, location: PropTypes.object,
mobile: PropTypes.bool, mobile: PropTypes.bool,
mobileTitle: PropTypes.string, mobileTitle: PropTypes.string,
@ -38,7 +42,8 @@ class App extends Component {
const { children, toast, unreadNotificationsCount, openInviteLightbox, const { children, toast, unreadNotificationsCount, openInviteLightbox,
mobile, mobileTitle, mobileTitleWidth, mobileTitleClick, location, mobile, mobileTitle, mobileTitleWidth, mobileTitleClick, location,
map, userRequested, requestAnswered, requestApproved, serverData, map, userRequested, requestAnswered, requestApproved, serverData,
onRequestAccess } = this.props onRequestAccess, notifications, fetchNotifications,
markAsRead, markAsUnread } = this.props
const { pathname } = location || {} const { pathname } = location || {}
// this fixes a bug that happens otherwise when you logout // this fixes a bug that happens otherwise when you logout
const currentUser = this.props.currentUser && this.props.currentUser.id ? this.props.currentUser : null const currentUser = this.props.currentUser && this.props.currentUser.id ? this.props.currentUser : null
@ -58,6 +63,10 @@ class App extends Component {
onRequestClick={onRequestAccess} />} onRequestClick={onRequestAccess} />}
{!mobile && <UpperRightUI currentUser={currentUser} {!mobile && <UpperRightUI currentUser={currentUser}
unreadNotificationsCount={unreadNotificationsCount} unreadNotificationsCount={unreadNotificationsCount}
notifications={notifications}
fetchNotifications={fetchNotifications}
markAsRead={markAsRead}
markAsUnread={markAsUnread}
openInviteLightbox={openInviteLightbox} openInviteLightbox={openInviteLightbox}
signInPage={pathname === '/login'} />} signInPage={pathname === '/login'} />}
<Toast message={toast} /> <Toast message={toast} />

View file

@ -0,0 +1,92 @@
import React from 'react'
import PropTypes from 'prop-types'
// based on https://www.npmjs.com/package/react-loading-animation
const loadingStyle = {
position: 'relative',
margin: '0 auto',
width: '30px',
height: '30px'
}
const svgStyle = {
animation: 'rotate 2s linear infinite',
height: '100%',
transformOrigin: 'center center',
width: '100%',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
margin: 'auto'
}
const circleStyle = {
strokeDasharray: '1,200',
strokeDashoffset: '0',
animation: 'dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite',
strokeLinecap: 'round'
}
const animation = `@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1,200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89,200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89,200;
stroke-dashoffset: -124px;
}
}
@keyframes color {
100%, 0% {
stroke: #a354cd;
}
50% {
stroke: #4fb5c0;
}
}`
class Loading extends React.Component {
static propTypes = {
style: PropTypes.object,
width: PropTypes.string,
height: PropTypes.string,
margin: PropTypes.string
}
static defaultProps = {
style: {},
width: '30px',
height: '30px',
margin: '0 auto'
}
render() {
let { width, height, margin, style } = this.props
loadingStyle.width = width
loadingStyle.height = height
loadingStyle.margin = margin
return <div style={Object.assign({}, loadingStyle, style)}>
<style>{animation}</style>
<svg style={svgStyle} viewBox="25 25 50 50">
<circle style={circleStyle} cx="50" cy="50" r="20" fill="none" strokeWidth="4" strokeMiterlimit="10"/>
</svg>
</div>
}
}
export default Loading

View file

@ -0,0 +1,127 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import outdent from 'outdent'
class Notification extends Component {
static propTypes = {
markAsRead: PropTypes.func,
markAsUnread: PropTypes.func,
notification: PropTypes.shape({
id: PropTypes.number.isRequired,
type: PropTypes.string.isRequired,
subject: PropTypes.string.isRequired,
is_read: PropTypes.bool.isRequired,
created_at: PropTypes.string.isRequired,
actor: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
image: PropTypes.string,
admin: PropTypes.boolean
}),
object: PropTypes.object,
map: PropTypes.object,
topic: PropTypes.object,
topic1: PropTypes.object,
topic2: PropTypes.object
})
}
notificationTextHtml = () => {
const { notification } = this.props
let map, topic, topic1, topic2
let result = `<div class='in-bold'>${notification.actor.name}</div>`
switch (notification.type) {
case 'ACCESS_APPROVED':
map = notification.data.map
result += outdent`granted your request to edit map
<span class='in-bold'>${map.name}</span>`
break
case 'ACCESS_REQUEST':
map = notification.data.map
result += outdent`wants permission to map with you on
<span class='in-bold'>${map.name}</span>`
if (!notification.data.object.answered) {
result += '<br /><div class="action">Offer a response</div>'
}
break
case 'INVITE_TO_EDIT':
map = notification.data.map
result += outdent`gave you edit access to map
<span class='in-bold'>${map.name}</span>`
break
case 'TOPIC_ADDED_TO_MAP':
map = notification.data.map
topic = notification.data.topic
result += outdent`added topic <span class='in-bold'>${topic.name}</span>
to map <span class='in-bold'>${map.name}</span>`
break
case 'TOPIC_CONNECTED_1':
topic1 = notification.data.topic1
topic2 = notification.data.topic2
result += outdent`connected <span class='in-bold'>${topic1.name}</span>
to <span class='in-bold'>${topic2.name}</span>`
break
case 'TOPIC_CONNECTED_2':
topic1 = notification.data.topic1
topic2 = notification.data.topic2
result += outdent`connected <span class='in-bold'>${topic2.name}</span>
to <span class='in-bold'>${topic1.name}</span>`
break
case 'MESSAGE_FROM_DEVS':
result += notification.subject
}
return {__html: result}
}
getDate = () => {
const { notification: {created_at} } = this.props
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const created = new Date(created_at)
return `${months[created.getMonth()]} ${created.getDate()}`
}
markAsRead = () => {
const { notification, markAsRead } = this.props
markAsRead(notification.id)
}
markAsUnread = () => {
const { notification, markAsUnread } = this.props
markAsUnread(notification.id)
}
render = () => {
const { notification } = this.props
const classes = `notification ${notification.is_read ? 'read' : 'unread'}`
if (!notification.data.object) {
return null
}
return <li className={classes}>
<a href={`/notifications/${notification.id}`}>
<div className='notification-actor'>
<img src={notification.actor.image} />
</div>
<div className='notification-body'
dangerouslySetInnerHTML={this.notificationTextHtml()} />
</a>
<div className='notification-read-unread'>
{!notification.is_read && <div onClick={this.markAsRead}>
mark read
</div>}
{notification.is_read && <div onClick={this.markAsUnread}>
mark unread
</div>}
</div>
<div className='notification-date'>
{this.getDate()}
</div>
<div className='clearfloat'></div>
</li>
}
}
export default Notification