From 77868903263e618eaa4078f4ebd7a44811283929 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 24 Feb 2025 21:42:18 +0100 Subject: [PATCH] Add effective date to terms of service --- .../terms_of_service/drafts_controller.rb | 4 +- .../admin/terms_of_service_controller.rb | 2 +- .../instances/terms_of_services_controller.rb | 10 ++- app/javascript/mastodon/api/instance.ts | 8 +- app/javascript/mastodon/api_types/instance.ts | 4 +- .../features/terms_of_service/index.tsx | 80 ++++++++++++++----- app/javascript/mastodon/features/ui/index.jsx | 2 +- app/javascript/mastodon/locales/en.json | 2 + app/javascript/styles/mastodon/forms.scss | 15 +++- app/models/terms_of_service.rb | 27 ++++++- .../rest/terms_of_service_serializer.rb | 27 +++++++ .../terms_of_service/drafts/show.html.haml | 3 + .../admin/terms_of_service/index.html.haml | 6 +- .../terms_of_service_changed.html.haml | 2 +- .../terms_of_service_changed.text.erb | 4 +- config/locales/activerecord.en.yml | 4 + config/locales/en.yml | 5 +- config/locales/simple_form.en.yml | 2 + config/routes.rb | 3 +- config/routes/api.rb | 2 + ...add_effective_date_to_terms_of_services.rb | 7 ++ db/schema.rb | 3 +- .../terms_of_service_fabricator.rb | 1 + 23 files changed, 180 insertions(+), 43 deletions(-) create mode 100644 app/serializers/rest/terms_of_service_serializer.rb create mode 100644 db/migrate/20250224144617_add_effective_date_to_terms_of_services.rb diff --git a/app/controllers/admin/terms_of_service/drafts_controller.rb b/app/controllers/admin/terms_of_service/drafts_controller.rb index 02cb05946f..0c67eb9df8 100644 --- a/app/controllers/admin/terms_of_service/drafts_controller.rb +++ b/app/controllers/admin/terms_of_service/drafts_controller.rb @@ -23,7 +23,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController private def set_terms_of_service - @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text) + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text, effective_date: 10.days.from_now) end def current_terms_of_service @@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController def resource_params params - .expect(terms_of_service: [:text, :changelog]) + .expect(terms_of_service: [:text, :changelog, :effective_date]) end end diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb index f70bfd2071..10aa5c66ca 100644 --- a/app/controllers/admin/terms_of_service_controller.rb +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -3,6 +3,6 @@ class Admin::TermsOfServiceController < Admin::BaseController def index authorize :terms_of_service, :index? - @terms_of_service = TermsOfService.live.first + @terms_of_service = TermsOfService.published.first end end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb index e9e8e8ef55..0a861dd7bb 100644 --- a/app/controllers/api/v1/instances/terms_of_services_controller.rb +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -5,12 +5,18 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo def show cache_even_if_authenticated! - render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer + render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer end private def set_terms_of_service - @terms_of_service = TermsOfService.live.first! + @terms_of_service = begin + if params[:date].present? + TermsOfService.published.find_by!(effective_date: params[:date]) + else + TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet + end + end end end diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts index ec9146fb34..764e8daab2 100644 --- a/app/javascript/mastodon/api/instance.ts +++ b/app/javascript/mastodon/api/instance.ts @@ -4,8 +4,12 @@ import type { ApiPrivacyPolicyJSON, } from 'mastodon/api_types/instance'; -export const apiGetTermsOfService = () => - apiRequestGet('v1/instance/terms_of_service'); +export const apiGetTermsOfService = (version?: string) => + apiRequestGet( + version + ? `v1/instance/terms_of_service/${version}` + : 'v1/instance/terms_of_service', + ); export const apiGetPrivacyPolicy = () => apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts index ead9774515..3a29684b70 100644 --- a/app/javascript/mastodon/api_types/instance.ts +++ b/app/javascript/mastodon/api_types/instance.ts @@ -1,5 +1,7 @@ export interface ApiTermsOfServiceJSON { - updated_at: string; + effective_date: string; + effective: boolean; + succeeded_by: string | null; content: string; } diff --git a/app/javascript/mastodon/features/terms_of_service/index.tsx b/app/javascript/mastodon/features/terms_of_service/index.tsx index 05033bffec..8ef64fc515 100644 --- a/app/javascript/mastodon/features/terms_of_service/index.tsx +++ b/app/javascript/mastodon/features/terms_of_service/index.tsx @@ -8,26 +8,31 @@ import { } from 'react-intl'; import { Helmet } from 'react-helmet'; +import { Link, useParams } from 'react-router-dom'; import { apiGetTermsOfService } from 'mastodon/api/instance'; import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance'; import { Column } from 'mastodon/components/column'; -import { Skeleton } from 'mastodon/components/skeleton'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; const messages = defineMessages({ title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' }, }); +interface Params { + date?: string; +} + const TermsOfService: React.FC<{ multiColumn: boolean; }> = ({ multiColumn }) => { const intl = useIntl(); + const { date } = useParams(); const [response, setResponse] = useState(); const [loading, setLoading] = useState(true); useEffect(() => { - apiGetTermsOfService() + apiGetTermsOfService(date) .then((data) => { setResponse(data); setLoading(false); @@ -36,7 +41,7 @@ const TermsOfService: React.FC<{ .catch(() => { setLoading(false); }); - }, []); + }, [date]); if (!loading && !response) { return ; @@ -55,23 +60,60 @@ const TermsOfService: React.FC<{ defaultMessage='Terms of Service' /> -

- - ) : ( - + {response?.effective ? ( + + ), + }} + /> + ) : ( + + ), + }} + /> + )} + + {response?.succeeded_by && ( + <> + {' · '} + + + ), + }} /> - ), - }} - /> + + + )}

diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index b239e63ccd..79a6a364e1 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -205,7 +205,7 @@ class SwitchingColumnsArea extends PureComponent { - + diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 83699b34ed..5a6e40cd8a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -872,7 +872,9 @@ "subscribed_languages.target": "Change subscribed languages for {target}", "tabs_bar.home": "Home", "tabs_bar.notifications": "Notifications", + "terms_of_service.effective_as_of": "Effective as of {date}", "terms_of_service.title": "Terms of Service", + "terms_of_service.upcoming_changes_on": "Upcoming changes on {date}", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 09ec0e9e41..7df7e14b2b 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -340,10 +340,17 @@ code { columns: unset; } - .input.datetime .label_input select { - display: inline-block; - width: auto; - flex: 0; + .input.datetime .label_input, + .input.date .label_input { + display: flex; + gap: 4px; + align-items: center; + + select { + display: inline-block; + width: auto; + flex: 0; + } } .input.select.select--languages { diff --git a/app/models/terms_of_service.rb b/app/models/terms_of_service.rb index 1f0832dc9a..704e2dccf2 100644 --- a/app/models/terms_of_service.rb +++ b/app/models/terms_of_service.rb @@ -6,6 +6,7 @@ # # id :bigint(8) not null, primary key # changelog :text default(""), not null +# effective_date :date # notification_sent_at :datetime # published_at :datetime # text :text default(""), not null @@ -13,17 +14,27 @@ # updated_at :datetime not null # class TermsOfService < ApplicationRecord - scope :published, -> { where.not(published_at: nil).order(published_at: :desc) } - scope :live, -> { published.limit(1) } + scope :published, -> { where.not(published_at: nil).order(effective_date: :desc) } + scope :live, -> { published.where('effective_date < now()').limit(1) } scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) } validates :text, presence: true - validates :changelog, presence: true, if: -> { published? } + validates :changelog, :effective_date, presence: true, if: -> { published? } + + validate :effective_date_cannot_be_in_the_past def published? published_at.present? end + def effective? + published? && effective_date.past? + end + + def succeeded_by + TermsOfService.published.where(effective_date: (effective_date..)).where.not(id: id).first + end + def notification_sent? notification_sent_at.present? end @@ -31,4 +42,14 @@ class TermsOfService < ApplicationRecord def scope_for_notification User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at)) end + + private + + def effective_date_cannot_be_in_the_past + return if effective_date.blank? + + min_date = TermsOfService.live.pick(:effective_date) || Time.zone.today + + errors.add(:effective_date, :too_soon, date: min_date) if effective_date < min_date + end end diff --git a/app/serializers/rest/terms_of_service_serializer.rb b/app/serializers/rest/terms_of_service_serializer.rb new file mode 100644 index 0000000000..7f48788693 --- /dev/null +++ b/app/serializers/rest/terms_of_service_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class REST::TermsOfServiceSerializer < ActiveModel::Serializer + attributes :effective_date, :effective, :content, :succeeded_by + + def effective_date + object.effective_date.iso8601 + end + + def effective + object.effective? + end + + def succeeded_by + object.succeeded_by&.effective_date&.iso8601 + end + + def content + markdown.render(format(object.text, domain: Rails.configuration.x.local_domain)) + end + + private + + def markdown + @markdown ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true) + end +end diff --git a/app/views/admin/terms_of_service/drafts/show.html.haml b/app/views/admin/terms_of_service/drafts/show.html.haml index 7a9a6fd3c4..6afefd4aae 100644 --- a/app/views/admin/terms_of_service/drafts/show.html.haml +++ b/app/views/admin/terms_of_service/drafts/show.html.haml @@ -14,6 +14,9 @@ .fields-group = form.input :changelog, wrapper: :with_block_label, input_html: { rows: 8 } + .fields-group + = form.input :effective_date, wrapper: :with_block_label, as: :date + .actions = form.button :button, t('admin.terms_of_service.save_draft'), type: :submit, name: :action_type, value: :save_draft, class: 'button button-secondary' = form.button :button, t('admin.terms_of_service.publish'), type: :submit, name: :action_type, value: :publish diff --git a/app/views/admin/terms_of_service/index.html.haml b/app/views/admin/terms_of_service/index.html.haml index 809d567674..457ef42670 100644 --- a/app/views/admin/terms_of_service/index.html.haml +++ b/app/views/admin/terms_of_service/index.html.haml @@ -10,7 +10,11 @@ .admin__terms-of-service__container__header .dot-indicator.success .dot-indicator__indicator - %span= t('admin.terms_of_service.live') + %span + - if @terms_of_service.effective? + = t('admin.terms_of_service.live') + - else + = t('admin.terms_of_service.going_live_on_html', date: tag.time(l(@terms_of_service.effective_date), class: 'formatted', date: @terms_of_service.effective_date.iso8601)) · %span = t('admin.terms_of_service.published_on_html', date: tag.time(l(@terms_of_service.published_at.to_date), class: 'formatted', date: @terms_of_service.published_at.to_date.iso8601)) diff --git a/app/views/user_mailer/terms_of_service_changed.html.haml b/app/views/user_mailer/terms_of_service_changed.html.haml index 95cc976418..bfa7f774af 100644 --- a/app/views/user_mailer/terms_of_service_changed.html.haml +++ b/app/views/user_mailer/terms_of_service_changed.html.haml @@ -9,7 +9,7 @@ %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } %tr %td.email-inner-card-td.email-prose - %p= t('user_mailer.terms_of_service_changed.description_html', path: terms_of_service_url, domain: site_hostname) + %p= t('user_mailer.terms_of_service_changed.description_html', path: terms_of_service_version_url(date: @terms_of_service.effective_date), domain: site_hostname, date: l(@terms_of_service.effective_date)) %p %strong= t('user_mailer.terms_of_service_changed.changelog') = markdown(@terms_of_service.changelog) diff --git a/app/views/user_mailer/terms_of_service_changed.text.erb b/app/views/user_mailer/terms_of_service_changed.text.erb index 8416572f0a..d9212b158a 100644 --- a/app/views/user_mailer/terms_of_service_changed.text.erb +++ b/app/views/user_mailer/terms_of_service_changed.text.erb @@ -2,9 +2,9 @@ === -<%= t('user_mailer.terms_of_service_changed.description', domain: site_hostname) %> +<%= t('user_mailer.terms_of_service_changed.description', domain: site_hostname, date: @terms_of_service.effective_date) %> -=> <%= terms_of_service_url %> +=> <%= terms_of_service_version_url(date: @terms_of_service.effective_date) %> <%= t('user_mailer.terms_of_service_changed.changelog') %> diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index f10a9011b3..ed389c1323 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -49,6 +49,10 @@ en: attributes: reblog: taken: of post already exists + terms_of_service: + attributes: + effective_date: + too_soon: is too soon, must be later than %{date} user: attributes: email: diff --git a/config/locales/en.yml b/config/locales/en.yml index fdf73bad9e..a8b2d9516c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -939,6 +939,7 @@ en: chance_to_review_html: "The generated terms of service will not be published automatically. You will have a chance to review the results. Please fill in the necessary details to proceed." explanation_html: The terms of service template provided is for informational purposes only, and should not be construed as legal advice on any subject matter. Please consult with your own legal counsel on your situation and specific legal questions you have. title: Terms of Service Setup + going_live_on_html: Live, effective %{date} history: History live: Live no_history: There are no recorded changes of the terms of service yet. @@ -1937,8 +1938,8 @@ en: terms_of_service_changed: agreement: By continuing to use %{domain}, you are agreeing to these terms. If you disagree with the updated terms, you may terminate your agreement with %{domain} at any time by deleting your account. changelog: 'At a glance, here is what this update means for you:' - description: 'You are receiving this e-mail because we''re making some changes to our terms of service at %{domain}. We encourage you to review the updated terms in full here:' - description_html: You are receiving this e-mail because we're making some changes to our terms of service at %{domain}. We encourage you to review the updated terms in full here. + description: 'You are receiving this e-mail because we''re making some changes to our terms of service at %{domain}. These updates will take effect %{date}. We encourage you to review the updated terms in full here:' + description_html: You are receiving this e-mail because we're making some changes to our terms of service at %{domain}. These updates will take effect %{date}. We encourage you to review the updated terms in full here. sign_off: The %{domain} team subject: Updates to our terms of service subtitle: The terms of service of %{domain} are changing diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index edf0a29a32..423422f599 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -132,6 +132,7 @@ en: name: You can only change the casing of the letters, for example, to make it more readable terms_of_service: changelog: Can be structured with Markdown syntax. + effective_date: A reasonable timeframe can range anywhere from 10 to 30 days from the date you notify your users. text: Can be structured with Markdown syntax. terms_of_service_generator: admin_email: Legal notices include counternotices, court orders, takedown requests, and law enforcement requests. @@ -333,6 +334,7 @@ en: usable: Allow posts to use this hashtag locally terms_of_service: changelog: What's changed? + effective_date: Effective date text: Terms of Service terms_of_service_generator: admin_email: Email address for legal notices diff --git a/config/routes.rb b/config/routes.rb index 5adec04c7d..e31fbcb06d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -204,7 +204,8 @@ Rails.application.routes.draw do get '/privacy-policy', to: 'privacy#show', as: :privacy_policy get '/terms-of-service', to: 'terms_of_service#show', as: :terms_of_service - get '/terms', to: redirect('/terms-of-service') + get '/terms-of-service/:date', to: 'terms_of_service#show', as: :terms_of_service_version + get '/terms', to: redirect('/terms-of-service') match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false diff --git a/config/routes/api.rb b/config/routes/api.rb index 34a267b35d..55b86aa80c 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -121,6 +121,8 @@ namespace :api, format: false do resource :translation_languages, only: [:show] resource :languages, only: [:show] resource :activity, only: [:show], controller: :activity + + get '/terms_of_service/:date', to: 'terms_of_services#show' end end diff --git a/db/migrate/20250224144617_add_effective_date_to_terms_of_services.rb b/db/migrate/20250224144617_add_effective_date_to_terms_of_services.rb new file mode 100644 index 0000000000..e46378387d --- /dev/null +++ b/db/migrate/20250224144617_add_effective_date_to_terms_of_services.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEffectiveDateToTermsOfServices < ActiveRecord::Migration[8.0] + def change + add_column :terms_of_services, :effective_date, :date + end +end diff --git a/db/schema.rb b/db/schema.rb index 6c4af6aa19..2f814c931b 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[8.0].define(version: 2025_01_29_144813) do +ActiveRecord::Schema[8.0].define(version: 2025_02_24_144617) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1110,6 +1110,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do t.datetime "notification_sent_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.date "effective_date" end create_table "tombstones", force: :cascade do |t| diff --git a/spec/fabricators/terms_of_service_fabricator.rb b/spec/fabricators/terms_of_service_fabricator.rb index 2b0cfabcfb..09e1d88235 100644 --- a/spec/fabricators/terms_of_service_fabricator.rb +++ b/spec/fabricators/terms_of_service_fabricator.rb @@ -5,4 +5,5 @@ Fabricator(:terms_of_service) do changelog { Faker::Lorem.paragraph } published_at { Time.zone.now } notification_sent_at { Time.zone.now } + effective_date { Time.zone.tomorrow } end