diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb new file mode 100644 index 0000000000..e9e8e8ef55 --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController + before_action :set_terms_of_service + + def show + cache_even_if_authenticated! + render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.live.first! + end +end diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts new file mode 100644 index 0000000000..52a8393d55 --- /dev/null +++ b/app/javascript/mastodon/api/instance.ts @@ -0,0 +1,5 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance'; + +export const apiGetTermsOfService = () => + apiRequestGet('v1/instance/terms_of_service'); diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts new file mode 100644 index 0000000000..60afeb1448 --- /dev/null +++ b/app/javascript/mastodon/api_types/instance.ts @@ -0,0 +1,4 @@ +export interface ApiTermsOfServiceJSON { + updated_at: string; + content: string; +} diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 65a36520d6..e7d357f4c6 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -18,7 +18,7 @@ import { Icon } from 'mastodon/components/icon'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; import Account from 'mastodon/containers/account_container'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter} from 'mastodon/features/ui/components/link_footer'; const messages = defineMessages({ title: { id: 'column.about', defaultMessage: 'About' }, diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index 8d26115dfa..ece06953ea 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -25,7 +25,7 @@ import StarIcon from '@/material-icons/400-24px/star.svg?react'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; diff --git a/app/javascript/mastodon/features/terms_of_service/index.tsx b/app/javascript/mastodon/features/terms_of_service/index.tsx new file mode 100644 index 0000000000..843530430b --- /dev/null +++ b/app/javascript/mastodon/features/terms_of_service/index.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; + +import { + FormattedMessage, + FormattedDate, + useIntl, + defineMessages, +} from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +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'; + +const messages = defineMessages({ + title: { id: 'terms_of_service.title', defaultMessage: 'Terms Of Service' }, +}); + +const TermsOfService: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const [response, setResponse] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiGetTermsOfService() + .then((data) => { + setResponse(data); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, []); + + return ( + +
+
+

+ +

+

+ + ) : ( + + ), + }} + /> +

+
+ + {response && ( +
+ )} +
+ + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default TermsOfService; diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.jsx b/app/javascript/mastodon/features/ui/components/compose_panel.jsx index 18321cbe63..b085b2dc2a 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/compose_panel.jsx @@ -7,10 +7,9 @@ import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/ import ServerBanner from 'mastodon/components/server_banner'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import SearchContainer from 'mastodon/features/compose/containers/search_container'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import LinkFooter from './link_footer'; - class ComposePanel extends PureComponent { static propTypes = { identity: identityContextPropShape, diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx deleted file mode 100644 index 49b21c2e48..0000000000 --- a/app/javascript/mastodon/features/ui/components/link_footer.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage, injectIntl } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { connect } from 'react-redux'; - -import { openModal } from 'mastodon/actions/modal'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'mastodon/initial_state'; -import { PERMISSION_INVITE_USERS } from 'mastodon/permissions'; - -const mapDispatchToProps = (dispatch) => ({ - onLogout () { - dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); - - }, -}); - -class LinkFooter extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - multiColumn: PropTypes.bool, - onLogout: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleLogoutClick = e => { - e.preventDefault(); - e.stopPropagation(); - - this.props.onLogout(); - - return false; - }; - - render () { - const { signedIn, permissions } = this.props.identity; - const { multiColumn } = this.props; - - const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); - const canProfileDirectory = profileDirectory; - - const DividingCircle = {' · '}; - - return ( -
-

- {domain}: - {' '} - - {statusPageUrl && ( - <> - {DividingCircle} - - - )} - {canInvite && ( - <> - {DividingCircle} - - - )} - {canProfileDirectory && ( - <> - {DividingCircle} - - - )} - {DividingCircle} - -

- -

- Mastodon: - {' '} - - {DividingCircle} - - {DividingCircle} - - {DividingCircle} - - {DividingCircle} - v{version} -

-
- ); - } - -} - -export default injectIntl(withIdentity(connect(null, mapDispatchToProps)(LinkFooter))); diff --git a/app/javascript/mastodon/features/ui/components/link_footer.tsx b/app/javascript/mastodon/features/ui/components/link_footer.tsx new file mode 100644 index 0000000000..28b361a7e4 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/link_footer.tsx @@ -0,0 +1,95 @@ +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { + domain, + version, + source_url, + statusPageUrl, + profile_directory as canProfileDirectory, +} from 'mastodon/initial_state'; + +const DividingCircle: React.FC = () => {' · '}; + +export const LinkFooter: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + return ( +
+

+ {domain}:{' '} + + + + {statusPageUrl && ( + <> + + + + + + )} + {canProfileDirectory && ( + <> + + + + + + )} + + + + + + + + +

+ +

+ Mastodon:{' '} + + + + + + + + + + + + + + + + + v{version} +

+
+ ); +}; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index daa4585ead..f2e28932ee 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -70,6 +70,7 @@ import { Onboarding, About, PrivacyPolicy, + TermsOfService, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; @@ -197,6 +198,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5a85c856d2..bff27bd0ec 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -202,6 +202,10 @@ export function PrivacyPolicy () { return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy'); } +export function TermsOfService () { + return import(/*webpackChunkName: "features/terms_of_service" */'../../terms_of_service'); +} + export function NotificationRequests () { return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 60b35cb31a..2610d001de 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -43,6 +43,7 @@ * @property {boolean=} use_pending_items * @property {string} version * @property {string} sso_redirect + * @property {string} status_page_url */ /** @@ -115,7 +116,6 @@ export const usePendingItems = getMeta('use_pending_items'); export const version = getMeta('version'); export const languages = initialState?.languages; export const criticalUpdatesPending = initialState?.critical_updates_pending; -// @ts-expect-error export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); diff --git a/app/models/terms_of_service.rb b/app/models/terms_of_service.rb new file mode 100644 index 0000000000..d07166912e --- /dev/null +++ b/app/models/terms_of_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: terms_of_services +# +# id :bigint(8) not null, primary key +# changelog :text default(""), not null +# notification_sent_at :datetime +# published_at :datetime +# text :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class TermsOfService < ApplicationRecord + scope :live, -> { where.not(published_at: nil).order(published_at: :desc).limit(1) } +end diff --git a/config/routes.rb b/config/routes.rb index 0f4df757da..564864c0f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -234,8 +234,9 @@ Rails.application.routes.draw do get '/about', to: 'about#show' get '/about/more', to: redirect('/about') - get '/privacy-policy', to: 'privacy#show', as: :privacy_policy - get '/terms', to: redirect('/privacy-policy') + get '/privacy-policy', to: 'privacy#show', as: :privacy_policy + get '/terms-of-service', to: 'privacy#show', as: :terms_of_service + 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 86e41a2abe..34a267b35d 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -116,6 +116,7 @@ namespace :api, format: false do resources :rules, only: [:index] resources :domain_blocks, only: [:index] resource :privacy_policy, only: [:show] + resource :terms_of_service, only: [:show] resource :extended_description, only: [:show] resource :translation_languages, only: [:show] resource :languages, only: [:show] diff --git a/db/migrate/20241123224956_create_terms_of_services.rb b/db/migrate/20241123224956_create_terms_of_services.rb new file mode 100644 index 0000000000..dda2b0647c --- /dev/null +++ b/db/migrate/20241123224956_create_terms_of_services.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTermsOfServices < ActiveRecord::Migration[7.2] + def change + create_table :terms_of_services do |t| + t.text :text, null: false, default: '' + t.text :changelog, null: false, default: '' + t.datetime :published_at + t.datetime :notification_sent_at + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 30b16a8a82..de7d84cc78 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[7.2].define(version: 2024_11_04_082851) do +ActiveRecord::Schema[7.2].define(version: 2024_11_23_224956) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -191,8 +191,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.boolean "hide_collections" t.integer "avatar_storage_schema_version" t.integer "header_storage_schema_version" - t.datetime "sensitized_at", precision: nil t.integer "suspension_origin" + t.datetime "sensitized_at", precision: nil t.boolean "trendable" t.datetime "reviewed_at", precision: nil t.datetime "requested_review_at", precision: nil @@ -556,12 +556,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do end create_table "ip_blocks", force: :cascade do |t| - t.inet "ip", default: "0.0.0.0", null: false - t.integer "severity", default: 0, null: false - t.datetime "expires_at", precision: nil - t.text "comment", default: "", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.datetime "expires_at", precision: nil + t.inet "ip", default: "0.0.0.0", null: false + t.integer "severity", default: 0, null: false + t.text "comment", default: "", null: false t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true end @@ -1080,6 +1080,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.index ["tag_id"], name: "index_tag_follows_on_tag_id" end + create_table "tag_trends", force: :cascade do |t| + t.bigint "tag_id", null: false + t.float "score", default: 0.0, null: false + t.integer "rank", default: 0, null: false + t.boolean "allowed", default: false, null: false + t.string "language" + t.index ["tag_id", "language"], name: "index_tag_trends_on_tag_id_and_language", unique: true + end + create_table "tags", force: :cascade do |t| t.string "name", default: "", null: false t.datetime "created_at", precision: nil, null: false @@ -1096,6 +1105,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.index "lower((name)::text) text_pattern_ops", name: "index_tags_on_name_lower_btree", unique: true end + create_table "terms_of_services", force: :cascade do |t| + t.text "text", default: "", null: false + t.text "changelog", default: "", null: false + t.datetime "published_at" + t.datetime "notification_sent_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "tombstones", force: :cascade do |t| t.bigint "account_id" t.string "uri", null: false @@ -1343,6 +1361,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade add_foreign_key "tag_follows", "accounts", on_delete: :cascade add_foreign_key "tag_follows", "tags", on_delete: :cascade + add_foreign_key "tag_trends", "tags", on_delete: :cascade add_foreign_key "tombstones", "accounts", on_delete: :cascade add_foreign_key "user_invite_requests", "users", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade @@ -1380,9 +1399,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true create_view "user_ips", sql_definition: <<-SQL - SELECT user_id, - ip, - max(used_at) AS used_at + SELECT t0.user_id, + t0.ip, + max(t0.used_at) AS used_at FROM ( SELECT users.id AS user_id, users.sign_up_ip AS ip, users.created_at AS used_at @@ -1399,7 +1418,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do login_activities.created_at FROM login_activities WHERE (login_activities.success = true)) t0 - GROUP BY user_id, ip; + GROUP BY t0.user_id, t0.ip; SQL create_view "account_summaries", materialized: true, sql_definition: <<-SQL SELECT accounts.id AS account_id, @@ -1420,9 +1439,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL - SELECT account_id, - sum(rank) AS rank, - array_agg(reason) AS reason + SELECT t0.account_id, + sum(t0.rank) AS rank, + array_agg(t0.reason) AS reason FROM ( SELECT account_summaries.account_id, ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank, 'most_followed'::text AS reason @@ -1446,8 +1465,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do WHERE (follow_recommendation_suppressions.account_id = statuses.account_id))))) GROUP BY account_summaries.account_id HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0 - GROUP BY account_id - ORDER BY (sum(rank)) DESC; + GROUP BY t0.account_id + ORDER BY (sum(t0.rank)) DESC; SQL add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true diff --git a/spec/fabricators/terms_of_service_fabricator.rb b/spec/fabricators/terms_of_service_fabricator.rb new file mode 100644 index 0000000000..f32d269578 --- /dev/null +++ b/spec/fabricators/terms_of_service_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:terms_of_service) do + text 'MyText' + changelog 'MyText' + notification_sent_at '2024-11-23 23:49:56' +end diff --git a/spec/models/terms_of_service_spec.rb b/spec/models/terms_of_service_spec.rb new file mode 100644 index 0000000000..a4b2f536af --- /dev/null +++ b/spec/models/terms_of_service_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TermsOfService do + pending "add some examples to (or delete) #{__FILE__}" +end