0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 16:23:34 +08:00
discourse/app/models/user_option.rb
Natalie Tay cff8f6ea4d
FEATURE: Allow selection of multiple content languages when viewing topics and posts (#42128)
Related:
https://meta.discourse.org/t/multi-language-preferences-for-displaying-localized-content/381116

Allow anon and logged in users to set more than one language that they
understand, when content localization is enabled.


### User preferences

In user preferences, we now include a Content Languages section when the
site has Content Localization enabled.

### Topic menu

In topics where there are any languages that's not the user's language,
the option will appear to set their preferred content languages. This
appears for anon and logged in users, but anon users will be asked to
logged in to set the languages.
__________

These preferences apply only to topic titles and posts. They do not
affect navigational features like categories, tags, or sidebar content.
The per-post option to show the original remains independent of the
user's preferences.

This PR temporarily retains `show_original_content` as a compatibility
field computed from `automatically_translate`, keeping cached clients
and rolling deployments safe. A follow-up PR will remove the legacy API
and frontend compatibility code, then retire the old database column
through the staged column-removal process.
2026-07-30 12:19:44 +08:00

360 lines
16 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserOption < ActiveRecord::Base
AUTO_MODE = 1
LIGHT_MODE = 2
DARK_MODE = 3
HOMEPAGES = {
# -1 => reserved for "custom homepage"
1 => "latest",
2 => "categories",
3 => "unread",
4 => "new",
5 => "top",
6 => "bookmarks",
7 => "unseen",
8 => "hot",
}
self.ignored_columns = [
"enable_experimental_sidebar", # TODO: Remove when 20250804021210_drop_enable_experimental_sidebar_user_option has been promoted to pre-deploy
"only_chat_push_notifications", # TODO(2027-01): replaced by push_notification_level; drop the column in a follow-up PR once this has shipped
"chat_send_shortcut", # TODO(2027-01): replaced by send_shortcut; drop the column in a follow-up PR once this has shipped
]
self.primary_key = :user_id
belongs_to :user
before_save :update_hide_profile_and_presence
before_create :set_defaults
after_save :update_tracked_topics
scope :human_users, -> { where("user_id > 0") }
enum :default_calendar, { none_selected: 0, ics: 1, google: 2 }, scopes: false
enum :push_notification_level, { none: 0, all: 1, chat_only: 2 }, prefix: true, scopes: false
enum :send_shortcut, { enter: 0, meta_enter: 1 }, prefix: true, scopes: false
def self.ensure_consistency!
sql = <<~SQL
SELECT u.id FROM users u
LEFT JOIN user_options o ON o.user_id = u.id
WHERE o.user_id IS NULL
SQL
DB.query_single(sql).each { |id| UserOption.create(user_id: id) }
end
def self.previous_replies_type
@previous_replies_type ||= Enum.new(always: 0, unless_emailed: 1, never: 2)
end
def self.like_notification_frequency_type
@like_notification_frequency_type ||=
Enum.new(always: 0, first_time_and_daily: 1, first_time: 2, never: 3)
end
def self.text_sizes
@text_sizes ||= Enum.new(smallest: 4, smaller: 3, normal: 0, larger: 1, largest: 2)
end
def self.title_count_modes
@title_count_modes ||= Enum.new(notifications: 0, contextual: 1)
end
def self.email_level_types
@email_level_type ||= Enum.new(always: 0, only_when_away: 1, never: 2)
end
def self.composition_mode_types
@composition_mode_types ||= Enum.new(markdown: 0, rich: 1)
end
validates :text_size_key, inclusion: { in: UserOption.text_sizes.values }
validates :email_level, inclusion: { in: UserOption.email_level_types.values }
validates :email_messages_level, inclusion: { in: UserOption.email_level_types.values }
validates :timezone, timezone: true
validate :understood_languages_are_supported, if: :will_save_change_to_understood_languages?
def set_defaults
self.mailing_list_mode = SiteSetting.default_email_mailing_list_mode
self.mailing_list_mode_frequency = SiteSetting.default_email_mailing_list_mode_frequency
self.email_level = SiteSetting.default_email_level
self.email_messages_level = SiteSetting.default_email_messages_level
self.automatically_unpin_topics = SiteSetting.default_topics_automatic_unpin
self.email_previous_replies = SiteSetting.default_email_previous_replies
self.email_in_reply_to = SiteSetting.default_email_in_reply_to
self.enable_quoting = SiteSetting.default_other_enable_quoting
self.enable_smart_lists = SiteSetting.default_other_enable_smart_lists
self.enable_defer = SiteSetting.default_other_enable_defer
self.enable_markdown_monospace_font = SiteSetting.default_other_enable_markdown_monospace_font
self.external_links_in_new_tab = SiteSetting.default_other_external_links_in_new_tab
self.dynamic_favicon = SiteSetting.default_other_dynamic_favicon
self.skip_new_user_tips = SiteSetting.default_other_skip_new_user_tips
self.new_topic_duration_minutes = SiteSetting.default_other_new_topic_duration_minutes
self.auto_track_topics_after_msecs = SiteSetting.default_other_auto_track_topics_after_msecs
self.notification_level_when_replying =
SiteSetting.default_other_notification_level_when_replying
self.like_notification_frequency = SiteSetting.default_other_like_notification_frequency
self.email_digests = SiteSetting.default_email_digest_frequency.to_i > 0
self.digest_after_minutes = SiteSetting.default_email_digest_frequency.to_i
self.include_tl0_in_digests = SiteSetting.default_include_tl0_in_digests
self.text_size = SiteSetting.default_text_size
self.title_count_mode = SiteSetting.default_title_count_mode
self.hide_profile = SiteSetting.default_hide_profile
self.hide_presence = SiteSetting.default_hide_presence
self.sidebar_link_to_filtered_list = SiteSetting.default_sidebar_link_to_filtered_list
self.sidebar_show_count_of_new_items = SiteSetting.default_sidebar_show_count_of_new_items
self.composition_mode = SiteSetting.default_composition_mode
self.watched_precedence_over_muted = SiteSetting.default_watched_precedence_over_muted
true
end
def mailing_list_mode
SiteSetting.disable_mailing_list_mode ? false : super
end
def redirected_to_top_yet?
last_redirected_to_top_at.present?
end
def light_mode_forced?
interface_color_mode == LIGHT_MODE
end
def dark_mode_forced?
interface_color_mode == DARK_MODE
end
def update_last_redirected_to_top!
key = "user:#{id}:update_last_redirected_to_top"
delay = SiteSetting.active_user_rate_limit_secs
# only update last_redirected_to_top_at once every minute
return unless Discourse.redis.setnx(key, "1")
Discourse.redis.expire(key, delay)
# delay the update
Jobs.enqueue_in(
delay / 2,
:update_top_redirection,
user_id: user_id,
redirected_at: Time.zone.now.to_s,
)
end
def should_be_redirected_to_top
redirected_to_top.present?
end
def redirected_to_top
# redirect is enabled
return unless SiteSetting.redirect_users_to_top_page
# PERF: bypass min_redirected_to_top query for users that were seen already
return if user.trust_level > 0 && user.last_seen_at && user.last_seen_at > 1.month.ago
# top must be in the top_menu
return unless SiteSetting.top_menu[/\btop\b/i]
# not enough topics
return unless period = SiteSetting.min_redirected_to_top_period(1.day.ago)
if !user.seen_before? || (user.trust_level == 0 && !redirected_to_top_yet?)
update_last_redirected_to_top!
return { reason: I18n.t("redirected_to_top_reasons.new_user"), period: period }
elsif user.last_seen_at < 1.month.ago
update_last_redirected_to_top!
return { reason: I18n.t("redirected_to_top_reasons.not_seen_in_a_month"), period: period }
end
# don't redirect to top
nil
end
def treat_as_new_topic_start_date
duration =
new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes.to_i
times = [
case duration
when User::NewTopicDuration::ALWAYS
user.created_at
when User::NewTopicDuration::LAST_VISIT
user.previous_visit_at || user.user_stat.new_since
else
duration.minutes.ago
end,
user.created_at,
Time.at(SiteSetting.min_new_topics_time).to_datetime,
]
times.max
end
def homepage
return HOMEPAGES[homepage_id] if HOMEPAGES.keys.include?(homepage_id)
"hot" if homepage_id == 8 && SiteSetting.top_menu_map.include?("hot")
end
def text_size
UserOption.text_sizes[text_size_key]
end
def text_size=(value)
self.text_size_key = UserOption.text_sizes[value.to_sym]
end
def title_count_mode
UserOption.title_count_modes[title_count_mode_key]
end
def title_count_mode=(value)
self.title_count_mode_key = UserOption.title_count_modes[value.to_sym]
end
def unsubscribed_from_all?
!mailing_list_mode && !email_digests && email_level == UserOption.email_level_types[:never] &&
email_messages_level == UserOption.email_level_types[:never]
end
def likes_notifications_disabled?
like_notification_frequency == UserOption.like_notification_frequency_type[:never]
end
def self.user_tzinfo(user_id)
timezone = UserOption.where(user_id: user_id).pluck(:timezone).first || "UTC"
tzinfo = nil
begin
tzinfo = ActiveSupport::TimeZone.find_tzinfo(timezone)
rescue TZInfo::InvalidTimezoneIdentifier
Rails.logger.warn(
"#{User.find_by(id: user_id)&.username} has the timezone #{timezone} set, we do not know how to parse it in Rails, fallback to UTC",
)
tzinfo = ActiveSupport::TimeZone.find_tzinfo("UTC")
end
tzinfo
end
private
def understood_languages_are_supported
if understood_languages.all? { |locale| LocaleSiteSetting.supported_locales.include?(locale) }
return
end
errors.add(:understood_languages, :invalid)
end
def update_hide_profile_and_presence
if hide_profile_changed? || hide_presence_changed?
self.hide_profile_and_presence = hide_profile || hide_presence
elsif hide_profile_and_presence_changed?
self.hide_profile = hide_profile_and_presence
self.hide_presence = hide_profile_and_presence
end
end
def update_tracked_topics
return unless saved_change_to_auto_track_topics_after_msecs?
TrackedTopicsUpdater.new(id, auto_track_topics_after_msecs).call
end
end
# == Schema Information
#
# Table name: user_options
#
# ai_search_discoveries :boolean default(TRUE), not null
# allow_private_messages :boolean default(TRUE), not null
# auto_image_caption :boolean default(FALSE), not null
# auto_track_topics_after_msecs :integer
# automatically_translate :boolean default(TRUE), not null
# automatically_unpin_topics :boolean default(TRUE), not null
# bookmark_auto_delete_preference :integer default(3), not null
# chat_announce_new_messages :boolean default(TRUE), not null
# chat_email_frequency :integer default("when_away"), not null
# chat_enabled :boolean default(TRUE), not null
# chat_header_indicator_preference :integer default("all_new"), not null
# chat_new_message_sound :boolean default(FALSE), not null
# chat_quick_reaction_type :integer default("frequent"), not null
# chat_quick_reactions_custom :string
# chat_separate_sidebar_mode :integer default("default"), not null
# chat_sound :string
# composition_mode :integer default(1), not null
# default_calendar :integer default("none_selected"), not null
# digest_after_minutes :integer
# discourse_rewind_dismissed_at :datetime
# discourse_rewind_enabled :boolean default(TRUE), not null
# discourse_rewind_share_publicly :boolean default(FALSE), not null
# dismissed_channel_retention_reminder :boolean
# dismissed_dm_retention_reminder :boolean
# dynamic_favicon :boolean default(FALSE), not null
# email_digests :boolean
# email_in_reply_to :boolean default(TRUE), not null
# email_level :integer default(1), not null
# email_messages_level :integer default(0), not null
# email_previous_replies :integer default(2), not null
# enable_allowed_pm_users :boolean default(FALSE), not null
# enable_defer :boolean default(FALSE), not null
# enable_markdown_monospace_font :boolean default(TRUE), not null
# enable_quoting :boolean default(TRUE), not null
# enable_smart_lists :boolean default(TRUE), not null
# enable_upcoming_change_available_notifications :boolean default(TRUE), not null
# external_links_in_new_tab :boolean default(FALSE), not null
# hide_presence :boolean default(FALSE), not null
# hide_profile :boolean default(FALSE), not null
# hide_profile_and_presence :boolean default(FALSE), not null
# ignore_channel_wide_mention :boolean
# include_tl0_in_digests :boolean default(FALSE)
# interface_color_mode :integer default(1), not null
# last_redirected_to_top_at :datetime
# like_notification_frequency :integer default(1), not null
# mailing_list_mode :boolean default(FALSE), not null
# mailing_list_mode_frequency :integer default(1), not null
# new_topic_duration_minutes :integer
# notification_level_when_assigned :integer default("watch_topic"), not null
# notification_level_when_replying :integer
# notify_on_linked_posts :boolean default(TRUE), not null
# notify_on_solved :boolean default(TRUE), not null
# oldest_search_log_date :datetime
# policy_email_frequency :integer default("never"), not null
# push_notification_level :integer default("all"), not null
# seen_popups :integer is an Array
# send_shortcut :integer default("enter"), not null
# show_original_content :boolean default(FALSE), not null
# show_thread_title_prompts :boolean default(TRUE), not null
# sidebar_link_to_filtered_list :boolean default(FALSE), not null
# sidebar_show_count_of_new_items :boolean default(FALSE), not null
# skip_new_user_tips :boolean default(FALSE), not null
# text_size_key :integer default(0), not null
# text_size_seq :integer default(0), not null
# theme_ids :integer default([]), not null, is an Array
# theme_key_seq :integer default(0), not null
# timezone :string
# title_count_mode_key :integer default(0), not null
# topics_unread_when_closed :boolean default(TRUE), not null
# understood_languages :string default([]), not null, is an Array
# watched_precedence_over_muted :boolean default(FALSE), not null
# color_scheme_id :integer
# dark_scheme_id :integer
# homepage_id :integer
# user_id :integer not null, primary key
#
# Indexes
#
# index_user_options_on_user_id (user_id) UNIQUE
# index_user_options_on_user_id_and_default_calendar (user_id,default_calendar)
# index_user_options_on_watched_precedence_over_muted (watched_precedence_over_muted)
#