Created
September 9, 2024 18:33
-
-
Save jeremysmithco/507875af8726ec69a3a6ed25d3a56b36 to your computer and use it in GitHub Desktop.
Riffing on Topic Subscriptions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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