Skip to content

Instantly share code, notes, and snippets.

@jeremysmithco
Created September 9, 2024 18:33
Show Gist options
  • Save jeremysmithco/507875af8726ec69a3a6ed25d3a56b36 to your computer and use it in GitHub Desktop.
Save jeremysmithco/507875af8726ec69a3a6ed25d3a56b36 to your computer and use it in GitHub Desktop.
Riffing on Topic Subscriptions
class User
has_many :subscriptions
has_many :followings
end
class Channel
has_many :topics
has_many :subscriptions
end
class Subscription
belongs_to :user
belongs_to :channel
enum role: [:admin, :moderator, :regular]
enum follow: [:everything, :referenced, :nothing] # Default :everything for moderator, :referenced for other roles
end
class Topic
belongs_to :channel
belongs_to :last_post
has_many :posts
has_many :followings
end
class Post
belongs_to :topic
belongs_to :user # author
has_many :mentioned_users
end
class Following
belongs_to :topic
belongs_to :user
belongs_to :last_read_post
belongs_to :current_read_post
belongs_to :next_unread_post
attribute :archived_at, :datetime
end
# This approach means followings (and subsequently inbox entries) can only be created for users that
# are already subscribed to the channel. If a post author at mentions a user that isn't subscribed
# to this channel, they won't get a following record. And if a non-subscribed user tried to post on
# this topic they wouldn't get a following record. So maybe the best thing to do is require the user to
# join a channel before they can post, and before their username will show up in the at mention list.
# I think this is basically how Slack works. If you don't join an public channel, you can view it, but
# you can't post in it and it tells you to join everywhere.
# class NewTopicJob
# def perform(topic_id)
# topic = Topic.find(topic_id)
# topic_subscriptions = topic.channel.subscriptions.where(follow: :all)
# mention_subscriptions = topic.channel.subscriptions.where(follow: :referenced, user: topic.post.user)
# author_subscription = topic.channel.subscriptions.where(follow: :referenced, user: topic.post.mentioned_users)
# end
# end
class NewPostJob
def perform(post_id)
post = Post.find(post_id)
# update the last post
post.topic.update(last_post: post) if post.topic.last_post.blank? || post.topic.last_post_id < post.id
everything = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :everything))
# mentioned_users need to be created before this
mentions = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :referenced, user: post.mentioned_users))
# participating_users need to be created before this
participants = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :referenced, user: post.topic.participating_users))
all_users = everything + mentions + participants
all_users.each do |user|
following = Following.find_or_initialize_by(topic: post.topic, user: user, archived_at: nil)
# set next_unread_post to this new post, unless next_unread_post is already bigger than current_read_post
following.assign_attributes(next_unread_post: post) if following.next_unread_post.blank? || following.next_unread_post_id > following.current_read_post_id
following.save!
end
end
end
class Account::InboxController
def inbox
@followings = current_user.followings.includes(current_read_post: { topic: :last_post }).order(last_post: :desc)
end
end
class TopicsController
def show
@topic = Topic.find(params[:topic_id])
@posts = @topic.posts
@following = current_user.followings.find_by(topic: topic)
@following.update(last_read_post: @following.current_read_post, current_read_post: @posts.last) if @following.present?
end
end
# account/inbox/index.html.erb
# This isn't quite right. In the inbox, it should show the next post after the current_read_post, which
# may or may not be the topic's last_post. This probably means that the following create/update needs to
# set the next_post
<% @followings.each do |following| %>
<% if following.topic.last_post_id > following.current_read_post_id %>
Unread
<% end %>
<%= following.topic.title %>
<%= if following.topic.last_post.body %>
<% end %>
# posts/_post.html.erb
<% if post == following.previous_read_post %>
------------------ New
<% end %>
class User
has_many :subscriptions
has_many :followings
end
class Channel
has_many :topics
has_many :subscriptions
end
class Subscription
belongs_to :user
belongs_to :channel
enum role: [:admin, :moderator, :regular]
enum follow: [:everything, :referenced, :nothing] # Default :everything for moderator, :referenced for other roles
end
class Topic
belongs_to :channel
belongs_to :last_post
has_many :posts
has_many :followings
end
class Post
belongs_to :topic
belongs_to :user # author
has_many :mentioned_users
end
class Following
belongs_to :topic
belongs_to :user
belongs_to :first_unread_post
attribute :archived_at, :datetime # Moves following from inbox to archive
attribute :snooze_ended_at, :datetime # Will keep following out of inbox until this time
end
# This approach means followings (and subsequently inbox entries) can only be created for users that
# are already subscribed to the channel. If a post author at mentions a user that isn't subscribed
# to this channel, they won't get a following record. And if a non-subscribed user tried to post on
# this topic they wouldn't get a following record. So maybe the best thing to do is require the user to
# join a channel before they can post, and before their username will show up in the at mention list.
# I think this is basically how Slack works. If you don't join an public channel, you can view it, but
# you can't post in it and it tells you to join everywhere.
# class NewTopicJob
# def perform(topic_id)
# topic = Topic.find(topic_id)
# topic_subscriptions = topic.channel.subscriptions.where(follow: :all)
# mention_subscriptions = topic.channel.subscriptions.where(follow: :referenced, user: topic.post.user)
# author_subscription = topic.channel.subscriptions.where(follow: :referenced, user: topic.post.mentioned_users)
# end
# end
class NewPostJob
def perform(post_id)
post = Post.find(post_id)
# update the last post
post.topic.update(last_post: post) if post.topic.last_post.blank? || post.topic.last_post_id < post.id
everything = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :everything))
# mentioned_users need to be created before this
mentions = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :referenced, user: post.mentioned_users))
# participating_users need to be created before this
participants = User.all.includes(:subscriptions).references(:subscriptions).merge(post.topic.channel.subscriptions.where(follow: :referenced, user: post.topic.participating_users))
all_users = everything + mentions + participants
all_users.each do |user|
following = Following.find_or_initialize_by(topic: post.topic, user: user, archived_at: nil)
# set first_unread_post to this new post, only if first_unread_post is blank
following.assign_attributes(first_unread_post: post) if following.first_unread_post.blank?
following.save!
end
# what about users that are set to follow nothing on their channel subscription, but they manually follow a topic?
# how will they get their following record first_uread_post updated?
# Another option would be to create all the necessary followings first, and then run a couple updates
# CREATE ALL THE MISSING FOLLOWING RECORDS HERE...
ActiveRecord::Base.transaction do
Following.where(topic: post.topic, first_unread_post: nil).update_all(first_unread_post: post)
Following.where(topic: post.topic).where.not(archived_at: nil).update_all(archived_at: nil)
end
end
end
class Account::InboxController
def inbox
@followings = current_user.followings.includes(:first_unread_post, { topic: :last_post })
.where(snooze_ended_at: nil).or.where(snooze_ended_at: ..Time.current)
.order(last_post: :desc)
end
end
class TopicsController
def show
@topic = Topic.find(params[:topic_id])
@posts = @topic.posts
@following = current_user.followings.find_by(topic: topic)
if @following.present?
@first_unread_post = @following.first_unread_post
@following.update(first_unread_post: nil)
end
end
end
# account/inbox/index.html.erb
# This isn't quite right. In the inbox, it should show the next post after the current_read_post, which
# may or may not be the topic's last_post. This probably means that the following create/update needs to
# set the next_post
<% @followings.each do |following| %>
<% if following.first_unread_post.present? %>
[Unread]
<%= following.topic.title %>
<%= following.first_unread_post.body %>
<% else %>
[Read]
<%= following.topic.title %>
<%= following.topic.last_post.body %>
<% end %>
<% end %>
# posts/_post.html.erb
<% if post == @first_unread_post %>
------------------ [New]
<%= post.body %>
<% end %>
class User
has_many :channel_subscriptions
has_many :topic_subscriptions
attribute :digest_enabled_at, :datetime # When the email digest feature was turned on
attribute :digest_last_delivered_at, :datetime # Tracks when last notification delivery was made for all inboxed topics for the user
end
class Channel
has_many :topics
has_many :channel_subscriptions
end
class ChannelSubscription
belongs_to :user
belongs_to :channel
enum role: [:admin, :moderator, :regular]
enum delivery: [:everything, :referenced, :nothing] # Default :everything for moderator, :referenced for other roles
end
class Topic
belongs_to :channel
belongs_to :last_post
has_many :posts
has_many :topic_subscriptions
end
class Post
belongs_to :topic
belongs_to :user # author
has_many :mentioned_users
end
# I don't think we want to use belongs_to :channel_subscription here because a topic might change channel, and then
# we'd have to update every topic_subscription when that happened.
class TopicSubscription
belongs_to :user
belongs_to :topic
belongs_to :first_unread_post
enum delivery: [:everything, :referenced, :nothing] # :default uses Subscription setting, the others override
attribute :delivery_overriden_at, :datetime # Set when user manually overrides the delivery setting
attribute :inboxed_at, :datetime # Adds subscription to inbox
attribute :archived_at, :datetime # Moves subscription from inbox to archive
attribute :snooze_ended_at, :datetime # Will keep subscription out of inbox until this time
scope :inboxed -> { where.not(inboxed_at: nil) }
scope :unarchived -> { where(archived_at: nil) }
scope :unsnoozed -> { where(snooze_ended_at: nil).or.where(snooze_ended_at: ..Time.current) }
def should_deliver?(post)
if nothing?
return false
elsif everything?
return true
elsif referenced?
user.in?(post.mentioned_users & post.topic.participating_users)
end
end
def override_delivery(delivery)
update(delivery: delivery, delivery_overriden_at: Time.current)
end
end
class ProcessPostJob
def perform(post_id)
post = Post.find(post_id)
# update the last post (unless current last_post happens to be newer)
post.topic.update(last_post: post) if post.topic.last_post.blank? || post.topic.last_post_id < post.id
topic.add_participating_user(post.user)
post.update_mentioned_users
post.topic.channel.channel_subscriptions.each do |channel_subscription|
topic_subscription = Topic::Subscription.find_or_initialize_by(topic: post.topic, user: channel_subscription.user)
topic_subscription.assign_attributes(delivery: channel_subscription.delivery)
topic_subscription.assign_attributes(first_unread_post: post) if topic_subscription.first_unread_post.blank?
topic_subscription.assign_attributes(inboxed_at: post.created_at, archived_at: nil) if topic_subscription.should_deliver?(post)
topic_subscription.save
end
end
end
class Account::InboxController
def inbox
@topic_subscriptions = current_user.topic_subscriptions.inboxed.unsnoozed.unarchived
.includes(:first_unread_post, { topic: :last_post })
.order(last_post: :desc)
end
end
class TopicsController
def show
@topic = Topic.find(params[:topic_id])
@posts = @topic.posts
# If no topic_subscription, we need to set deliver to the channel_subscription.deliver
@topic_subscription = Topic::Subscription.find_or_create_by(topic: topic, user: current_user)
@first_unread_post = @topic_subscription.first_unread_post
@topic_subscription.update(first_unread_post: nil) if @topic_subscription.first_unread_post.present?
end
end
class Topic::SubscriptionDeliveriesController
def create
topic = Topic.find(params[:topic_id])
topic_subscription = Topic::Subscription.find_or_initialize_by(user: current_user, topic: topic)
topic_subscription.override_delivery(params[:delivery])
end
end
class DailyDigestJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
# Find all inboxed, unarchived, unsnoozed topics
# Find all notifiable posts for those topics, based on subscription rules
# * greater than or equal to last_unread_post
# * created after last_delivered_at (if present)
# * post matches should_deliver? rules
# Compile those topics and posts into digest email
# Mark last_delivered_at on user
posts = user.topic_subscriptions.inboxed.unsnoozed.unarchived.includes(topic: :post)
.where("topic_subscriptions.first_unread_post IS NOT NULL AND posts.id >= topic_subscriptions.first_unread_post AND posts.created_at > ?", user.digest_last_delivered_at)
.filter { |post| post.topic_subscription.should_deliver?(post) }
if UserMailer.daily_digest(user, digest_posts).deliver
user.update(digest_last_delivered_at: Time.current)
end
end
end
# account/inbox/index.html.erb
<% @topic_subscriptions.each do |topic_subscription| %>
<% if topic_subscription.first_unread_post.present? %>
[Unread]
<%= topic_subscription.topic.title %>
<%= topic_subscription.first_unread_post.body %>
<% else %>
[Read]
<%= topic_subscription.topic.title %>
<%= topic_subscription.topic.last_post.body %>
<% end %>
<% end %>
# topics/index.html.erb
<% @topic_subscriptions.each do |topic_subscription| %>
<% if topic_subscription.first_unread_post.present? %>
[Unread]
<%= topic_subscription.topic.title %>
<%= topic_subscription.first_unread_post.body %>
<% else %>
[Read]
<%= topic_subscription.topic.title %>
<%= topic_subscription.topic.last_post.body %>
<% end %>
<% end %>
# posts/_post.html.erb
<% if post == @first_unread_post %>
------------------ [New]
<%= post.body %>
<% end %>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment