diff --git a/app/mailers/digest_mailer.rb b/app/mailers/digest_mailer.rb new file mode 100644 index 000000000..0ffe6668c --- /dev/null +++ b/app/mailers/digest_mailer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DigestMailer < ApplicationMailer + helper :accounts + helper :statuses + helper :routing + + before_action do + @account = params[:account] + end + + def weekly + @weekly_highlights = weekly_highlights(@account) + + locale_for_account @account do + mail to: email_address_with_name(@account.user_email, @account.username) + end + end + + private + + def weekly_highlights(account) + Status.joins(:trend_highlight, :account) + .merge(Account.discoverable) + .where(StatusTrendHighlight.arel_table[:period].gteq(10.days.ago)) + .not_excluded_by_account(account) + .not_domain_blocked_by_account(account) + .reorder(Arel::Nodes::Case.new.when(StatusTrendHighlight.arel_table[:language].in(account.chosen_languages || account.user_locale)).then(1).else(0).desc, score: :desc) + .limit(20) + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 67463b140..91a3249ff 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -77,6 +77,7 @@ class Status < ApplicationRecord has_one :status_stat, inverse_of: :status has_one :poll, inverse_of: :status, dependent: :destroy has_one :trend, class_name: 'StatusTrend', inverse_of: :status + has_one :trend_highlight, class_name: 'StatusTrendHighlight', inverse_of: :status validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } diff --git a/app/models/status_trend.rb b/app/models/status_trend.rb index b0f1b6942..ef9e861a7 100644 --- a/app/models/status_trend.rb +++ b/app/models/status_trend.rb @@ -18,4 +18,5 @@ class StatusTrend < ApplicationRecord belongs_to :account scope :allowed, -> { joins('INNER JOIN (SELECT account_id, MAX(score) AS max_score FROM status_trends GROUP BY account_id) AS grouped_status_trends ON status_trends.account_id = grouped_status_trends.account_id AND status_trends.score = grouped_status_trends.max_score').where(allowed: true) } + scope :below_rank, ->(rank) { where(arel_table[:rank].lteq(rank)) } end diff --git a/app/models/status_trend_highlight.rb b/app/models/status_trend_highlight.rb new file mode 100644 index 000000000..102933f5d --- /dev/null +++ b/app/models/status_trend_highlight.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: status_trend_highlights +# +# id :bigint(8) not null, primary key +# period :datetime not null +# status_id :bigint(8) not null +# account_id :bigint(8) not null +# score :float default(0.0), not null +# language :string +# + +class StatusTrendHighlight < ApplicationRecord + belongs_to :status + belongs_to :account +end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb index 84bff9c02..61e94b97d 100644 --- a/app/models/trends/statuses.rb +++ b/app/models/trends/statuses.rb @@ -60,6 +60,7 @@ class Trends::Statuses < Trends::Base def refresh(at_time = Time.now.utc) statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account) calculate_scores(statuses, at_time) + update_weekly_highlights(at_time) end def request_review @@ -123,4 +124,18 @@ class Trends::Statuses < Trends::Base StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id') end end + + def update_weekly_highlights(at_time) + highlights = StatusTrend.allowed.below_rank(20) + + highlights.find_each do |trend| + # Forced to resort to this monstrosity because upsert_all's on_duplicate option is only + # available starting with Rails 7... + StatusTrendHighlight.connection.exec_insert(<<~SQL.squish, nil, [[nil, at_time.beginning_of_day], [nil, trend.status_id], [nil, trend.account_id], [nil, trend.score], [nil, trend.language]]) + INSERT INTO status_trend_highlights(period, status_id, account_id, score, language) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (status_id) DO UPDATE SET score = GREATEST(status_trend_highlights.score, EXCLUDED.score) + SQL + end + end end diff --git a/app/views/digest_mailer/weekly.html.haml b/app/views/digest_mailer/weekly.html.haml new file mode 100644 index 000000000..a07b2c7f6 --- /dev/null +++ b/app/views/digest_mailer/weekly.html.haml @@ -0,0 +1,19 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %h1 Your weekly digest + +- @weekly_highlights.each_with_index do |status, i| + = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true, time_zone: @account.user_time_zone \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 306324c04..4324432d1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -66,6 +66,8 @@ Rails.application.configure do config.action_mailer.default_options = { from: 'notifications@localhost' } + config.action_mailer.preview_path = Rails.root.join('spec', 'mailers', 'previews') + # If using a Heroku, Vagrant or generic remote development environment, # use letter_opener_web, accessible at /letter_opener. # Otherwise, use letter_opener, which launches a browser window to view sent mail. diff --git a/db/migrate/20230605085712_create_status_trend_highlights.rb b/db/migrate/20230605085712_create_status_trend_highlights.rb new file mode 100644 index 000000000..09e55183f --- /dev/null +++ b/db/migrate/20230605085712_create_status_trend_highlights.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateStatusTrendHighlights < ActiveRecord::Migration[6.1] + def change + create_table :status_trend_highlights do |t| # rubocop:disable Rails/CreateTableWithTimestamps + t.datetime :period, null: false + t.bigint :status_id, null: false, index: { unique: true } + t.bigint :account_id, null: false, index: true + t.float :score, null: false, default: 0.0 + t.string :language + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 9866b1014..1cd11d5c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_06_05_085711) do +ActiveRecord::Schema.define(version: 2023_06_05_085712) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -934,6 +934,16 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true end + create_table "status_trend_highlights", force: :cascade do |t| + t.datetime "period", null: false + t.bigint "status_id", null: false + t.bigint "account_id", null: false + t.float "score", default: 0.0, null: false + t.string "language" + t.index ["account_id"], name: "index_status_trend_highlights_on_account_id" + t.index ["status_id"], name: "index_status_trend_highlights_on_status_id", unique: true + end + create_table "status_trends", force: :cascade do |t| t.bigint "status_id", null: false t.bigint "account_id", null: false diff --git a/spec/mailers/previews/digest_mailer_preview.rb b/spec/mailers/previews/digest_mailer_preview.rb new file mode 100644 index 000000000..454706417 --- /dev/null +++ b/spec/mailers/previews/digest_mailer_preview.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3000/rails/mailers/digest_mailer + +class DigestMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/digest_mailer/weekly + def weekly + DigestMailer.with(account: Account.local.first).weekly + end +end