Ruby gem to manage read/unread status of ActiveRecord objects - and it's fast.
Features
- Manages unread records for anything you want readers (e.g. users) to read (like messages, documents, comments etc.)
- Supports mark as read to mark a single record as read
- Supports mark all as read to mark all records as read in a single step
- Gives you a scope to get the unread records for a given reader
- Needs only one additional database table
- Most important: Great performance
Requirements
- Ruby 3.1 or newer
- Rails 6.1 or newer (including Rails 7.2)
- MySQL, PostgreSQL or SQLite
- Needs a timestamp field in your models (like created_at or updated_at) with a database index on it
Changelog
https://github.com/ledermann/unread/releases
Installation
Step 1: Add this to your Gemfile:
and run
Step 2: Generate and run the migration:
rails g unread:migration rake db:migrate
Upgrade from previous releases
If you upgrade from an older release of this gem, you should read the upgrade notes.
Usage
class User < ActiveRecord::Base acts_as_reader # Optional: Allow a subset of users as readers only def self.reader_scope where(is_admin: true) end end class Message < ActiveRecord::Base acts_as_readable on: :created_at # The `on:` option sets the relevant attribute for comparing timestamps. # # The default is :updated_at, so updating a record, which was read by a # reader makes it unread again. # # Using :created_at, only new records will show up as unread. Updating a # record which was read by a reader, will NOT mark it as unread. # # Any other existing timestamp field can be used as `on:` option. end message1 = Message.create! message2 = Message.create! ## Get unread messages for a given user Message.unread_by(current_user) # => [ message1, message2 ] message1.mark_as_read! for: current_user Message.unread_by(current_user) # => [ message2 ] ## Get read messages for a given user Message.read_by(current_user) # => [ ] message1.mark_as_read! for: current_user Message.read_by(current_user) # => [ message1 ] ## Get all messages including the read status for a given user messages = Message.with_read_marks_for(current_user) # => [ message1, message2 ] messages[0].unread?(current_user) # => false messages[1].unread?(current_user) # => true Message.mark_as_read! :all, for: current_user Message.unread_by(current_user) # => [ ] Message.read_by(current_user) # => [ message1, message2 ] ## Get users that have not read a given message user1 = User.create! user2 = User.create! User.have_not_read(message1) # => [ user1, user2 ] message1.mark_as_read! for: user1 User.have_not_read(message1) # => [ user2 ] ## Get users that have read a given message User.have_read(message1) # => [ user1 ] message1.mark_as_read! for: user2 User.have_read(message1) # => [ user1, user2 ] Message.mark_as_read! :all, for: user1 User.have_not_read(message1) # => [ ] User.have_not_read(message2) # => [ user2 ] User.have_read(message1) # => [ user1, user2 ] User.have_read(message2) # => [ user1 ] ## Get all users including their read status for a given message users = User.with_read_marks_for(message1) # => [ user1, user2 ] users[0].have_read?(message1) # => true users[1].have_read?(message2) # => false # Optional: Cleaning up unneeded markers # Do this in a cron job once a day Message.cleanup_read_marks!
Getting read/unread stats through a relationship
class Document < ApplicationRecord has_many :comments end class Comment < ApplicationRecord acts_as_readable on: :created_at belongs_to :document end # Get unread comments count for a document document = Document.find(1) default_hash = Hash.new { |h, k| h[k] = { unread: 0, total: 0 } } document.comments.with_read_marks_for(current_user).reduce(default_hash) do |hash, comment| hash[comment.id][:unread] += 1 if comment.unread?(current_user) hash[comment.id][:total] += 1 hash end # => {20=>{:unread=>1, :total=>10}, 82=>{:unread=>0, :total=>4}
Using with_read_marks_for here is the key. It uses just one query and makes sure that the following unread? invocations use the result of the first query.
How does it work?
The main idea of this gem is to manage a list of read items for every reader after a certain timestamp.
The gem defines a scope doing a LEFT JOIN to this list, so your app can get the unread items in a performant manner. Of course, other scopes can be combined.
It will be ensured that the list of read items will not grow up too much:
- If a user uses "mark all as read", his list gets deleted and the timestamp is set to the current time.
- If a user never uses "mark all as read", the list will grow and grow with each item he reads. But there is help: Your app can use a cleanup method which removes unnecessary list items.
Overall, this gem can be used for large data. Please have a look at the generated SQL queries, here is an example:
# Assuming we have a user who has marked all messages as read on 2010-10-20 08:50 current_user = User.find(42) # Get the unread messages for this user Message.unread_by(current_user)
Generated query:
SELECT messages.* FROM messages LEFT JOIN read_marks ON read_marks.readable_type = "Message" AND read_marks.readable_id = messages.id AND read_marks.reader_id = 42 AND read_marks.reader_type = 'User' AND read_marks.timestamp >= messages.created_at WHERE read_marks.id IS NULL AND messages.created_at > '2010-10-20 08:50:00'
Hint: You should add a database index on messages.created_at.
Copyright (c) 2010-2023 Georg Ledermann and contributors, released under the MIT license