WIP
Carlos Martinez
The app needs a way to notify users they have been mentioned in a comment.
We are going to display these notifications through a notification panel, just like github or facebook display notifications to the users.
As in the future we want to add notifications for other events, the notification system should be generic enough to support any kind of event, as well of the panel component should be able to render any kind of notification.
Also, these notifications should be pushed in real time to the users, they should receive notifications without refreshing the browser.
- Websockets should be used instead of ajax polling.
- Notifications should be generic to support multiple types
For a notification system, there are multiple gems for rails that solve this problem very well, amongs them, the most popular is public_activity
.
The gem will allow us to track activities
for specific models, it is pretty easy to add custom activities, so this is what I'll use for notifications.
There are multiple approaches to push the notifications to the panel.
- Using ajax polling to fetch new activities in an interval
- Using websockets to create a connection between the client and the server
The first approach isn't very optimal, so we're going to implement an approach that involves using websockets to push notifications to the panel. For this, ActionCable comes handy, it easily allows us to create that realtime communication.
Here are examples on how to use action cable for RTM push:
The stack involved to add this feature is the following:
public_activity
for the nofitication system.ActionCable
to push new notifications to the user.Redis
is used by ActionCable as its subscription adapter.
The implementation will involve the following steps:
- Setting up
ActionCable
. - Setting up
public_activity
gem and migrations. - Adding an user_name field to users that is unique within a company.
- Fetch mentioned users from the comments.
- Create a custom :comment activity with the user ids mentioned.
- Execute a broadcast ActiveJob passing the new activity.
- Serialize the activity as JSON.
- Broadcast the activity to each user channel.
- Display the new activity in the client.
As we're already using Rails 5, ActionCable is already installed in the app. However, we still need to do some configuration to make it work properly.
- Install redis with
brew install redis
. - Ensure the redis server is running. (run
redis-server
). - Generate a new channel for notifications running
rails g channel Notifications
. - Add a route for the action cable server in the
routes.rb
- Add
gem 'public_activity'
to the Gemfile. - Run bundle install.
- Run
rails g public_activity:migration
. - Run
rails db:migrate
.
To make it easy to add mentions to users, we need a way to identify each user through text.
For example when the user types @car
and clicks on the user 'Carlos', we would like to insert an unique id that identifies that user through text, so we insert @carlows
in the comment. These usernames should be unique within a company.
To broadcast messages we will create a NotificationBroadcastJob
, this will run in a different process in parallel. Within this ActiveJob we will serialize the data for the notification as JSON and then broadcast to each user channel.
When a user connects to a channel, we start a stream using stream_from
. This is a nice opportunity to identify users.
For this, we'll use the current_user.id
like this:
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_from "notifications:#{current_user.id}"
end
end
This will allow us to broadcast data to an specific user this way:
user = User.first
ActionCable.server.broadcast "notifications:#{user.id}", message: 'hello world'
+------------------------+
| User creates |
| a comment mentioning |
| @carlos and @orlando |
+-----------+------------+
|
|
+------------------v--------------------+
| Server broadcasts the notification |
| to all mentioned users |
+------------------+--------------------+
|
+-------------------v--------------------+
| Every user connected receives the |
| notification in real time |
+----------------------------------------+
- Implementing a generic notification system
- Real time push notifications
- Notifications panel will fetch the last 10 notifications (if any)
class CreateActivities < ActiveRecord::Migration
def self.up
create_table :activities, id: :uuid do |t|
... default activity fields
t.text :content
t.timestamps
end
...
end
end
class User
# validate uniqueness of username with scope of company
end
...
mount ActionCable.server => '/cable'
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_from "notifications:#{current_user.id}"
end
end
class CommentsController < ApplicationController
def create
...
if @comment.save
create_notification(@comment)
end
end
def update
...
if @comment.save
create_notification(@comment)
end
end
private
create_notification(comment)
# fetch mentioned users with a regex
# find users by username
# create activity with:
# key: :mention
# content: @comment.content
# owner: current_user
# parameters: { users_involved: user_ids }
# schedule job to broadcast message
NotificationBroadcastJob.perform_later(created_activity, users)
end
end
In this broadcast we check if any user was passed, and we broadcast the notification to each user.
class MessageBroadcastJob < ApplicationJob
queue_as :default
def perform(notification, users_involved)
if any user involved
# serialize data from notification and user as JSON
# broadcast notification to each user involved
end
end
end
class ApplicationController
before_action :get_latest_notifications
protected
def get_latest_notifications
# Fetch latest notifications for current_user
end
end
class NotificationsPanel extends React.Component {
constructor(props) {
this.state = {
notifications: [],
unreadCount: 0,
opened: false
};
}
togglePanel() {
// toggle the panel opened state
}
openPanel() {
// set unreadCount to 0
}
componentDidMount() {
this.notificationSubscription = App.cable.subscriptions.create('NotificationsChannel', {
received: (data) => {
this.addNotificationToPanel(data);
}
});
}
addNotificationToPanel(data) {
// append new notification
// increment unreadCount
}
componentWillUnmount() {
this.notificationSubscription.unsubscribe();
}
render() {
// map notifications to an specific component
// e.g: notification.key === 'comment.mention'
// <MentionNotification />
return (
<div>
{ notifications }
</div>
);
}
}
- Prerendering is not working when using action cable