From e147947eb8c6e1d4ef53c49d502818def80786ce Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 9 Dec 2025 04:27:29 -0500 Subject: [PATCH 01/14] Add wrapstodon page spec (#37168) --- spec/requests/wrapstodon_spec.rb | 19 +++++++++++++++++++ spec/system/wrapstodon_spec.rb | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 spec/requests/wrapstodon_spec.rb create mode 100644 spec/system/wrapstodon_spec.rb diff --git a/spec/requests/wrapstodon_spec.rb b/spec/requests/wrapstodon_spec.rb new file mode 100644 index 0000000000..62bd4dacd9 --- /dev/null +++ b/spec/requests/wrapstodon_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Wrapstodon' do + let(:generated_annual_report) { AnnualReport.new(user.account, Time.current.year).generate } + let(:user) { Fabricate :user } + + describe 'GET /@:account_username/wrapstodon/:year/:share_key' do + context 'when share_key is invalid' do + it 'returns not found' do + get public_wrapstodon_path(account_username: user.account.username, year: generated_annual_report.year, share_key: 'sharks') + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/system/wrapstodon_spec.rb b/spec/system/wrapstodon_spec.rb new file mode 100644 index 0000000000..67d544dd47 --- /dev/null +++ b/spec/system/wrapstodon_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Wrapstodon' do + describe 'Viewing a wrapstodon' do + let(:generated_annual_report) { AnnualReport.new(user.account, Time.current.year).generate } + let(:user) { Fabricate :user } + + context 'when signed in' do + before { sign_in user } + + it 'visits the wrap page and renders the web app' do + visit public_wrapstodon_path(account_username: user.account.username, year: generated_annual_report.year, share_key: generated_annual_report.share_key) + + expect(page) + .to have_css('#wrapstodon') + .and have_private_cache_control + end + end + end +end From 9063c3b6606ae0a77f0f7b69e14003d6163973cb Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 9 Dec 2025 04:30:57 -0500 Subject: [PATCH 02/14] Remove yarn patch for `babel-plugin-lodash`, removed during Vite upgrade (#37161) --- .../babel-plugin-lodash-npm-3.3.4-c7161075b6.patch | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch diff --git a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch deleted file mode 100644 index 0b3f94d09e..0000000000 --- a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/lib/index.js b/lib/index.js -index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644 ---- a/lib/index.js -+++ b/lib/index.js -@@ -99,7 +99,7 @@ function lodash(_ref) { - - var node = _ref3; - -- if ((0, _types.isModuleDeclaration)(node)) { -+ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) { - isModule = true; - break; - } From eef40ba96b4b2d30b46b85ba88aeb4bec38e8b2f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:47:12 +0100 Subject: [PATCH 03/14] Update dependency hiredis-client to v0.26.2 (#37137) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ada28236f6..da4f618bb9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -304,8 +304,8 @@ GEM highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.26.1) - redis-client (= 0.26.1) + hiredis-client (0.26.2) + redis-client (= 0.26.2) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -703,7 +703,7 @@ GEM reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.26.1) + redis-client (0.26.2) connection_pool regexp_parser (2.11.3) reline (0.6.3) From 5347cabf3e1f9532873d096fd91e2afdeb55da95 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:07:35 +0100 Subject: [PATCH 04/14] Update dependency oj to v3.16.13 (#37135) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index da4f618bb9..43d9cc142b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,7 +469,7 @@ GEM nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.12) + oj (3.16.13) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) From ea768c17db5627caca1d8ebd3884851c7db75c6d Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Tue, 9 Dec 2025 11:31:35 +0100 Subject: [PATCH 05/14] Add counter cache to collections (#37176) --- .../api/v1_alpha/collections_controller.rb | 1 - app/models/collection.rb | 6 +----- app/models/collection_item.rb | 2 +- .../rest/base_collection_serializer.rb | 4 ---- ...251209093813_add_item_count_to_collections.rb | 7 +++++++ db/schema.rb | 3 ++- .../rest/base_collection_serializer_spec.rb | 16 ---------------- 7 files changed, 11 insertions(+), 28 deletions(-) create mode 100644 db/migrate/20251209093813_add_item_count_to_collections.rb diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index 65c08d8d6b..d0c4e0f3f0 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -71,7 +71,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController def set_collections @collections = @account.collections .with_tag - .with_item_count .order(created_at: :desc) .offset(offset_param) .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) diff --git a/app/models/collection.rb b/app/models/collection.rb index 231f12ef52..2e352cbe87 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -7,6 +7,7 @@ # id :bigint(8) not null, primary key # description :text not null # discoverable :boolean not null +# item_count :integer default(0), not null # local :boolean not null # name :string not null # original_number_of_items :integer @@ -39,11 +40,6 @@ class Collection < ApplicationRecord validate :items_do_not_exceed_limit scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } - scope :with_item_count, lambda { - select('collections.*, COUNT(collection_items.id)') - .left_joins(:collection_items) - .group(collections: :id) - } scope :with_tag, -> { includes(:tag) } def remote? diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb index 48a18592fd..093005fd3e 100644 --- a/app/models/collection_item.rb +++ b/app/models/collection_item.rb @@ -17,7 +17,7 @@ # collection_id :bigint(8) not null # class CollectionItem < ApplicationRecord - belongs_to :collection + belongs_to :collection, counter_cache: :item_count belongs_to :account, optional: true enum :state, diff --git a/app/serializers/rest/base_collection_serializer.rb b/app/serializers/rest/base_collection_serializer.rb index 0e9bfc4cfc..be26aac6fe 100644 --- a/app/serializers/rest/base_collection_serializer.rb +++ b/app/serializers/rest/base_collection_serializer.rb @@ -9,8 +9,4 @@ class REST::BaseCollectionSerializer < ActiveModel::Serializer def id object.id.to_s end - - def item_count - object.respond_to?(:item_count) ? object.item_count : object.collection_items.count - end end diff --git a/db/migrate/20251209093813_add_item_count_to_collections.rb b/db/migrate/20251209093813_add_item_count_to_collections.rb new file mode 100644 index 0000000000..071cfab46a --- /dev/null +++ b/db/migrate/20251209093813_add_item_count_to_collections.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddItemCountToCollections < ActiveRecord::Migration[8.0] + def change + add_column :collections, :item_count, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 4e8a9f6efb..51595381f5 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_12_02_140424) do +ActiveRecord::Schema[8.0].define(version: 2025_12_09_093813) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -380,6 +380,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_02_140424) do t.integer "original_number_of_items" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "item_count", default: 0, null: false t.index ["account_id"], name: "index_collections_on_account_id" t.index ["tag_id"], name: "index_collections_on_tag_id" end diff --git a/spec/serializers/rest/base_collection_serializer_spec.rb b/spec/serializers/rest/base_collection_serializer_spec.rb index c988a247e7..5ac6bc615d 100644 --- a/spec/serializers/rest/base_collection_serializer_spec.rb +++ b/spec/serializers/rest/base_collection_serializer_spec.rb @@ -38,20 +38,4 @@ RSpec.describe REST::BaseCollectionSerializer do 'updated_at' => match_api_datetime_format ) end - - describe 'Counting items' do - before do - Fabricate.times(2, :collection_item, collection:) - end - - it 'can count items on demand' do - expect(subject['item_count']).to eq 2 - end - - it 'can use precalculated counts' do - collection.define_singleton_method :item_count, -> { 8 } - - expect(subject['item_count']).to eq 8 - end - end end From 9702cbb41ceb9b7469ee7eb3280eca18644d4ed3 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 9 Dec 2025 11:36:08 +0100 Subject: [PATCH 06/14] Fix emoji on Wrapstodon (#37177) --- app/javascript/entrypoints/wrapstodon.tsx | 15 ++++++++++----- app/javascript/mastodon/actions/store.js | 4 +++- app/javascript/mastodon/reducers/compose.js | 4 +++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx index e1eebcce57..d599d30e67 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -2,10 +2,8 @@ import { createRoot } from 'react-dom/client'; import { Provider as ReduxProvider } from 'react-redux'; -import { - importFetchedAccounts, - importFetchedStatuses, -} from '@/mastodon/actions/importer'; +import { importFetchedStatuses } from '@/mastodon/actions/importer'; +import { hydrateStore } from '@/mastodon/actions/store'; import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; import { Router } from '@/mastodon/components/router'; import { WrapstodonShare } from '@/mastodon/features/annual_report/share'; @@ -33,7 +31,14 @@ function loaded() { if (!report) { throw new Error('Initial state report not found'); } - store.dispatch(importFetchedAccounts(initialState.accounts)); + + // Set up store + store.dispatch( + hydrateStore({ + meta: { locale: document.documentElement.lang }, + accounts: initialState.accounts, + }), + ); store.dispatch(importFetchedStatuses(initialState.statuses)); store.dispatch(setReport(report)); diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index e8fec13453..7a68679d44 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -22,6 +22,8 @@ export function hydrateStore(rawState) { dispatch(hydrateCompose()); dispatch(hydrateSearch()); - dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + if (rawState.accounts) { + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + } }; } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index c7e27abd45..e413f2893d 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -363,7 +363,9 @@ export const composeReducer = (state = initialState, action) => { switch(action.type) { case STORE_HYDRATE: - return hydrate(state, action.state.get('compose')); + if (action.state.get('compose')) + return hydrate(state, action.state.get('compose')); + return state; case COMPOSE_MOUNT: return state .set('mounted', state.get('mounted') + 1) From 4cdcdaa7d9f2a7dd4825060835c568e9e7cf1c33 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 9 Dec 2025 16:36:46 +0100 Subject: [PATCH 07/14] Fix streaming image build after removal of `.yarn` (#37181) --- streaming/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/streaming/Dockerfile b/streaming/Dockerfile index 3a12007f68..74c0c42aae 100644 --- a/streaming/Dockerfile +++ b/streaming/Dockerfile @@ -86,7 +86,6 @@ WORKDIR /opt/mastodon # Copy Node package configuration files from build system to container COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ -COPY .yarn /opt/mastodon/.yarn # Copy Streaming source code from build system to container COPY ./streaming /opt/mastodon/streaming From 697569e5f925858954cc10349257867659d87af6 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 9 Dec 2025 16:59:40 +0100 Subject: [PATCH 08/14] Add `account_id` attribute to `AnnualReport` entity (#37182) --- app/models/generated_annual_report.rb | 2 +- app/serializers/rest/annual_report_serializer.rb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/generated_annual_report.rb b/app/models/generated_annual_report.rb index 563dd9219c..60c7fe40b1 100644 --- a/app/models/generated_annual_report.rb +++ b/app/models/generated_annual_report.rb @@ -33,7 +33,7 @@ class GeneratedAnnualReport < ApplicationRecord when 1 data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id') when 2 - [] + [account_id] end end diff --git a/app/serializers/rest/annual_report_serializer.rb b/app/serializers/rest/annual_report_serializer.rb index 99c313e6cb..85a9c04540 100644 --- a/app/serializers/rest/annual_report_serializer.rb +++ b/app/serializers/rest/annual_report_serializer.rb @@ -3,9 +3,13 @@ class REST::AnnualReportSerializer < ActiveModel::Serializer include RoutingHelper - attributes :year, :data, :schema_version, :share_url + attributes :year, :data, :schema_version, :share_url, :account_id def share_url public_wrapstodon_url(object.account, object.year, object.share_key) if object.share_key.present? end + + def account_id + object.account_id.to_s + end end From ac71771d98b21c6418a97e0ae05294868619e3d5 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 9 Dec 2025 11:09:01 -0500 Subject: [PATCH 09/14] Fix misc comment typos (#37183) --- app/javascript/mastodon/models/notification_group.ts | 2 +- app/lib/application_extension.rb | 2 +- app/models/bulk_import.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index 7b88f90429..9a74d01e8a 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -11,7 +11,7 @@ import type { import type { ApiReportJSON } from 'mastodon/api_types/reports'; // Maximum number of avatars displayed in a notification group -// This corresponds to the max lenght of `group.sampleAccountIds` +// This corresponds to the max length of `group.sampleAccountIds` export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8; interface BaseNotificationGroup diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb index d8090d15bc..bc6c7561cc 100644 --- a/app/lib/application_extension.rb +++ b/app/lib/application_extension.rb @@ -28,7 +28,7 @@ module ApplicationExtension end def redirect_uris - # Doorkeeper stores the redirect_uri value as a newline delimeted list in + # Doorkeeper stores the redirect_uri value as a newline delimited list in # the database: redirect_uri.split end diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index e3e46d7b1c..8435c245a2 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -53,7 +53,7 @@ class BulkImport < ApplicationRecord BulkImport.increment_counter(:processed_items, bulk_import_id) BulkImport.increment_counter(:imported_items, bulk_import_id) if imported - # Since the incrementation has been done atomically, concurrent access to `bulk_import` is now bening + # Since the incrementation has been done atomically, concurrent access to `bulk_import` is now benign bulk_import = BulkImport.find(bulk_import_id) bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items end From 9d81561bb2440c8fb9a75bd05277120aff346b1e Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 9 Dec 2025 17:51:05 +0100 Subject: [PATCH 10/14] Update Wrapstodon design (#37169) --- app/helpers/application_helper.rb | 1 + app/javascript/entrypoints/wrapstodon.tsx | 4 +- .../images/archetypes/space_elements.png | Bin 0 -> 1129 bytes app/javascript/mastodon/components/status.jsx | 5 +- .../annual_report/announcement/index.tsx | 4 +- .../announcement/styles.module.scss | 6 +- .../features/annual_report/archetype.tsx | 226 ++++++++++-- .../features/annual_report/followers.tsx | 72 +--- .../annual_report/highlighted_post.tsx | 127 +++---- .../features/annual_report/index.module.scss | 283 +++++++++++++++ .../mastodon/features/annual_report/index.tsx | 142 +++++--- .../mastodon/features/annual_report/modal.tsx | 29 ++ .../annual_report/most_used_hashtag.tsx | 38 +- .../features/annual_report/new_posts.tsx | 52 +-- .../features/annual_report/share_button.tsx | 41 +++ ...hare.module.css => shared_page.module.css} | 4 +- .../{share.tsx => shared_page.tsx} | 6 +- .../ui/components/annual_report_modal.tsx | 20 - .../features/ui/util/async-components.js | 2 +- app/javascript/mastodon/locales/en.json | 42 ++- .../mastodon/models/annual_report.ts | 1 + app/javascript/styles/common.scss | 1 - .../styles/mastodon/annual_reports.scss | 342 ------------------ app/views/wrapstodon/show.html.haml | 2 + 24 files changed, 773 insertions(+), 677 deletions(-) create mode 100644 app/javascript/images/archetypes/space_elements.png create mode 100644 app/javascript/mastodon/features/annual_report/index.module.scss create mode 100644 app/javascript/mastodon/features/annual_report/modal.tsx create mode 100644 app/javascript/mastodon/features/annual_report/share_button.tsx rename app/javascript/mastodon/features/annual_report/{share.module.css => shared_page.module.css} (83%) rename app/javascript/mastodon/features/annual_report/{share.tsx => shared_page.tsx} (74%) delete mode 100644 app/javascript/mastodon/features/ui/components/annual_report_modal.tsx delete mode 100644 app/javascript/styles/mastodon/annual_reports.scss diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e1b8ebf38d..1076d9ced8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -155,6 +155,7 @@ module ApplicationHelper def html_classes output = [] + output << content_for(:html_classes) output << 'system-font' if current_account&.user&.setting_system_font_ui output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx index d599d30e67..7a74e18d52 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -6,7 +6,7 @@ import { importFetchedStatuses } from '@/mastodon/actions/importer'; import { hydrateStore } from '@/mastodon/actions/store'; import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; import { Router } from '@/mastodon/components/router'; -import { WrapstodonShare } from '@/mastodon/features/annual_report/share'; +import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page'; import { IntlProvider, loadLocale } from '@/mastodon/locales'; import { loadPolyfills } from '@/mastodon/polyfills'; import ready from '@/mastodon/ready'; @@ -48,7 +48,7 @@ function loaded() { - + , diff --git a/app/javascript/images/archetypes/space_elements.png b/app/javascript/images/archetypes/space_elements.png new file mode 100644 index 0000000000000000000000000000000000000000..8b83506b8e7336084a39e6a63e45d9d709383491 GIT binary patch literal 1129 zcmV-v1eW`WP)L_}XkMqoun zUPMG-L_}XiL|+MNUWotz02OpnPE!C7?;zhmfMCDBpD=&VaFCBsZ?CVbd>|hH00Y5E zL_t(|+U=a{j+-zLg~#K&jSab+_kY!fkhWR1yCPY$cqG1`Q51a|9ghbCp@V~igM)*E z14Z)i#*sLZSDvI%6h&zX(M&W(l0^zcmNSVVpC*Gj&=Kh*BO*Ei2>#JRfTF2?5;$1; zjZKh{>?^>rYMRkQWEAQjZ7@?GK>?pg0y)jDitgdAGFIXZp8%_YQH=4O0_Op0jndY8 zU!AMowl7FTEC?-iZB51Cnc}G}BX0Os!l$WV#)OcPn;8QGFq&GDaihsk>LCK)FnUcN zunA~9C^*BlkuhIt{;a!lZ~LvP5eUxGrznGwIzrh9PF~TMI52gCotQ_v;(I_R`A|by zj8{qbFtaNk5mry(wTDX2v2ZVFl-)C}27^-7Yr8?9J;@XE#nMY8z|(%%_{J2g0i$Ry zY<#qfmkaG^Cv1GQi#H0bX&3C({V^7jXzMJ!iQlVrSoNI?XtV#u@*)NhcPu*r;ts$E z^fgJZ;RF!}S0BhMZxKpt96esou5gBoL(OMrdCg9$v$O_1J6}i^vvZMk<~=)$YtWhH zIeUT5XUKCNz+4H^SL%oM%=tw&lc}WhH~igR`t;5S?Lk+n%9lYX%hE^iWaL<_x%~c46K= z8H@8sE%4?ic9Vq(uFXLEgXePzwTbdio^_=bb=GN7{(qRebbho9gJD6nrN8Nn;sjSTZjhhyBiSlSbZ zajo&d*gY2u5vTQkVlfjsxG!8_UobYi7x^K5T5HT>2Sr}79oorYfrIU$$agAOe^TOl z;hICWIx=NC$9e)cW+4F}gtj{mg=I9^k_7K3Nu{!088Z}1T= vqwoKb;9SQrb4khhEa@S+gM)*EgTD9$ElWTl)+tm%00000NkvXXu0mjf1~~!# literal 0 HcmV?d00001 diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 15f0b9da30..892270b394 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -117,6 +117,7 @@ class Status extends ImmutablePureComponent { hidden: PropTypes.bool, unread: PropTypes.bool, showThread: PropTypes.bool, + showActions: PropTypes.bool, isQuotedPost: PropTypes.bool, shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, @@ -381,7 +382,7 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props; + const { intl, hidden, featured, unfocusable, unread, showThread, showActions = true, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props; let { status, account, ...other } = this.props; @@ -618,7 +619,7 @@ class Status extends ImmutablePureComponent { )} - {!isQuotedPost && + {(showActions && !isQuotedPost) && } diff --git a/app/javascript/mastodon/features/annual_report/announcement/index.tsx b/app/javascript/mastodon/features/annual_report/announcement/index.tsx index 7cdb36e35f..67e1d7b3e5 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/index.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/index.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; + import { Button } from '@/mastodon/components/button'; import styles from './styles.module.scss'; @@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{ onOpen: () => void; }> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { return ( -
+

({ booster: { - id: 'annual_report.summary.archetype.booster', - defaultMessage: 'The cool-hunter', + id: 'annual_report.summary.archetype.booster.name', + defaultMessage: 'The Archer', }, replier: { - id: 'annual_report.summary.archetype.replier', - defaultMessage: 'The social butterfly', + id: 'annual_report.summary.archetype.replier.name', + defaultMessage: 'The Butterfly', }, pollster: { - id: 'annual_report.summary.archetype.pollster', - defaultMessage: 'The pollster', + id: 'annual_report.summary.archetype.pollster.name', + defaultMessage: 'The Wonderer', }, lurker: { - id: 'annual_report.summary.archetype.lurker', - defaultMessage: 'The lurker', + id: 'annual_report.summary.archetype.lurker.name', + defaultMessage: 'The Stoic', }, oracle: { - id: 'annual_report.summary.archetype.oracle', - defaultMessage: 'The oracle', + id: 'annual_report.summary.archetype.oracle.name', + defaultMessage: 'The Oracle', }, }); -export const Archetype: React.FC<{ - data: ArchetypeData; -}> = ({ data }) => { - const intl = useIntl(); - let illustration; +export const archetypeSelfDescriptions = defineMessages({ + booster: { + id: 'annual_report.summary.archetype.booster.desc_self', + defaultMessage: + 'You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.', + }, + replier: { + id: 'annual_report.summary.archetype.replier.desc_self', + defaultMessage: + 'You frequently replied to other people’s posts, pollinating Mastodon with new discussions.', + }, + pollster: { + id: 'annual_report.summary.archetype.pollster.desc_self', + defaultMessage: + 'You created more polls than other post types, cultivating curiosity on Mastodon.', + }, + lurker: { + id: 'annual_report.summary.archetype.lurker.desc_self', + defaultMessage: + 'We know you were out there, somewhere, enjoying Mastodon in your own quiet way.', + }, + oracle: { + id: 'annual_report.summary.archetype.oracle.desc_self', + defaultMessage: + 'You created new posts more than replies, keeping Mastodon fresh and future-facing.', + }, +}); - switch (data) { - case 'booster': - illustration = booster; - break; - case 'replier': - illustration = replier; - break; - case 'pollster': - illustration = pollster; - break; - case 'lurker': - illustration = lurker; - break; - case 'oracle': - illustration = oracle; - break; - } +export const archetypePublicDescriptions = defineMessages({ + booster: { + id: 'annual_report.summary.archetype.booster.desc_public', + defaultMessage: + '{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.', + }, + replier: { + id: 'annual_report.summary.archetype.replier.desc_public', + defaultMessage: + '{name} frequently replied to other people’s posts, pollinating Mastodon with new discussions.', + }, + pollster: { + id: 'annual_report.summary.archetype.pollster.desc_public', + defaultMessage: + '{name} created more polls than other post types, cultivating curiosity on Mastodon.', + }, + lurker: { + id: 'annual_report.summary.archetype.lurker.desc_public', + defaultMessage: + 'We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.', + }, + oracle: { + id: 'annual_report.summary.archetype.oracle.desc_public', + defaultMessage: + '{name} created new posts more than replies, keeping Mastodon fresh and future-facing.', + }, +}); + +const illustrations = { + booster, + replier, + pollster, + lurker, + oracle, +} as const; + +export const Archetype: React.FC<{ + report: AnnualReport; + account?: Account; + canShare: boolean; +}> = ({ report, account, canShare }) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const isSelfView = account?.id === me; + + const [isRevealed, setIsRevealed] = useState(!isSelfView); + const reveal = useCallback(() => { + setIsRevealed(true); + wrapperRef.current?.focus(); + }, []); + + const archetype = report.data.archetype; + const descriptions = isSelfView + ? archetypeSelfDescriptions + : archetypePublicDescriptions; + + const name = account?.display_name; return ( -
-
- {intl.formatMessage(archetypeNames[data])} +
+
+ {account && ( + + )} +
+ +
+
- +
+

+ {isSelfView ? ( + + ) : ( + + )} +

+

+ {isRevealed ? ( + intl.formatMessage(archetypeNames[archetype]) + ) : ( + + )} +

+

+ {isRevealed ? ( + intl.formatMessage(descriptions[archetype], { + name, + }) + ) : ( + + )} +

+
+ {!isRevealed && ( + + )} + {isRevealed && canShare && }
); }; diff --git a/app/javascript/mastodon/features/annual_report/followers.tsx b/app/javascript/mastodon/features/annual_report/followers.tsx index 196013ae9d..b0f2216bc5 100644 --- a/app/javascript/mastodon/features/annual_report/followers.tsx +++ b/app/javascript/mastodon/features/annual_report/followers.tsx @@ -1,68 +1,24 @@ import { FormattedMessage, FormattedNumber } from 'react-intl'; -import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import classNames from 'classnames'; -import { ShortNumber } from 'mastodon/components/short_number'; -import type { TimeSeriesMonth } from 'mastodon/models/annual_report'; +import styles from './index.module.scss'; export const Followers: React.FC<{ - data: TimeSeriesMonth[]; - total?: number; -}> = ({ data, total }) => { - const change = data.reduce((sum, item) => sum + item.followers, 0); - - const cumulativeGraph = data.reduce( - (newData, item) => [ - ...newData, - item.followers + (newData[newData.length - 1] ?? 0), - ], - [0], - ); - + count: number; +}> = ({ count }) => { return ( -
- - - - - - - - - +
+
+ +
- - - -
-
- {change > -1 ? '+' : '-'} - -
- -
- - - -
- }} - /> -
-
+
+
); diff --git a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx index 7edbb2e614..5ce4947609 100644 --- a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx +++ b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx @@ -1,102 +1,77 @@ /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, - @typescript-eslint/no-unsafe-assignment */ - -import { useCallback } from 'react'; + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call */ import { FormattedMessage } from 'react-intl'; -import { DisplayName } from '@/mastodon/components/display_name'; -import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; -import { DetailedStatus } from 'mastodon/features/status/components/detailed_status'; -import { me } from 'mastodon/initial_state'; +import classNames from 'classnames'; + +import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import type { TopStatuses } from 'mastodon/models/annual_report'; -import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { makeGetStatus } from 'mastodon/selectors'; +import { useAppSelector } from 'mastodon/store'; + +import styles from './index.module.scss'; const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any; -const getPictureInPicture = makeGetPictureInPicture() as unknown as ( - arg0: any, - arg1: any, -) => any; export const HighlightedPost: React.FC<{ data: TopStatuses; }> = ({ data }) => { - let statusId, label; + const { by_reblogs, by_favourites, by_replies } = data; - if (data.by_reblogs) { - statusId = data.by_reblogs; - label = ( - - ); - } else if (data.by_favourites) { - statusId = data.by_favourites; - label = ( - - ); - } else { - statusId = data.by_replies; - label = ( - - ); - } + const statusId = by_reblogs || by_favourites || by_replies; - const dispatch = useAppDispatch(); - const domain = useAppSelector((state) => state.meta.get('domain')); const status = useAppSelector((state) => statusId ? getStatus(state, { id: statusId }) : undefined, ); - const pictureInPicture = useAppSelector((state) => - statusId ? getPictureInPicture(state, { id: statusId }) : undefined, - ); - const account = useAppSelector((state) => - me ? state.accounts.get(me) : undefined, - ); - - const handleToggleHidden = useCallback(() => { - dispatch(toggleStatusSpoilers(statusId)); - }, [dispatch, statusId]); if (!status) { - return ( -
+ return
; + } + + let label; + if (by_reblogs) { + label = ( + + ); + } else if (by_favourites) { + label = ( + + ); + } else { + label = ( + ); } - const displayName = ( - - - , - }} - /> - - {label} - - ); - return ( -
- +
+
+

+ +

+

{label}

+
+ +
); }; diff --git a/app/javascript/mastodon/features/annual_report/index.module.scss b/app/javascript/mastodon/features/annual_report/index.module.scss new file mode 100644 index 0000000000..0258f9c798 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/index.module.scss @@ -0,0 +1,283 @@ +.modalWrapper { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 40px; + overflow-y: auto; + pointer-events: none; + scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary); + + .loading-indicator .circular-progress { + color: var(--lime); + } +} + +.closeButton { + position: absolute; + top: 24px; + right: 24px; + padding: 8px; + border-radius: 100%; + + --default-icon-color: var(--color-bg-primary); + --default-bg-color: var(--color-text-primary); + --hover-icon-color: var(--color-bg-primary); + --hover-bg-color: var(--color-text-primary); +} + +.wrapper { + position: relative; + max-width: 600px; + padding: 24px; + contain: layout; + flex: 0 0 auto; + pointer-events: auto; + color: var(--color-text-primary); + background: var(--color-bg-primary); + background: + radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%), + radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%), + radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%), + radial-gradient(at 16% 95%, #1e948299 0, transparent 50%), + radial-gradient(at 80% 91%, #16dae499 0, transparent 50%) + var(--color-bg-primary); + border-radius: 40px; + + @media (width < 600px) { + padding: 12px; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: inherit; + border-radius: inherit; + filter: blur(20px); + } +} + +.header { + margin-bottom: 18px; + text-align: center; + + h1 { + font-family: monospace; + text-transform: uppercase; + letter-spacing: 0.15em; + font-size: 30px; + font-weight: 600; + line-height: 1.5; + margin-bottom: 8px; + } + + p { + font-size: 14px; + line-height: 1.5; + } +} + +.stack { + --grid-spacing: 12px; + + display: grid; + gap: var(--grid-spacing); +} + +.box { + position: relative; + padding: 16px; + border-radius: 16px; + background: rgb(from var(--color-bg-primary) r g b / 60%); + box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%); + + &::before, + &::after { + content: ''; + position: absolute; + inset-inline: 0; + display: block; + height: 1px; + background-image: linear-gradient( + to right, + transparent, + white, + transparent + ); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } +} + +.content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: 16px; + font-size: 14px; + text-align: center; + text-wrap: balance; + + &.comfortable { + gap: 12px; + } +} + +.title { + text-transform: uppercase; + color: #c2c8ff; + font-weight: 500; +} + +.statLarge { + font-size: 24px; + font-weight: 500; + overflow-wrap: break-word; +} + +.statExtraLarge { + font-size: 32px; + font-weight: 500; + line-height: 1; + overflow-wrap: break-word; +} + +.mostBoostedPost { + padding: 0; + padding-top: 8px; + overflow: hidden; +} + +.statsGrid { + display: grid; + gap: var(--grid-spacing); + grid-template-columns: 1fr 2fr; + grid-template-areas: + 'followers hashtag' + 'new-posts hashtag'; + + @media (width < 680px) { + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'followers new-posts' + 'hashtag hashtag'; + } + + &:empty { + display: none; + } + + &.onlyHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'hashtag'; + } + + &.noHashtag { + grid-template-columns: 1fr 1fr; + grid-template-areas: 'followers new-posts'; + } + + &.singleNumber { + grid-template-columns: 1fr 2fr; + grid-template-areas: 'number hashtag'; + + @media (width < 680px) { + grid-template-areas: + 'number number' + 'hashtag hashtag'; + } + } + + &.singleNumber.noHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'number'; + } +} + +.followers { + grid-area: followers; + + .singleNumber & { + grid-area: number; + } +} + +.newPosts { + grid-area: new-posts; + + .singleNumber & { + grid-area: number; + } +} + +.mostUsedHashtag { + grid-area: hashtag; + padding-block: 24px; +} + +.archetype { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.archetypeArtboard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + width: 180px; + padding-top: 40px; +} + +.archetypeAvatar { + position: absolute; + top: 7px; + left: 4px; + border-radius: 100%; + overflow: hidden; +} + +.archetypeIllustrationWrapper { + position: relative; + width: 92px; + aspect-ratio: 1; + overflow: hidden; + border-radius: 100%; + + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset -10px -4px 15px #00000080; + } +} + +.archetypeIllustration { + width: 100%; +} + +.blurredImage { + filter: blur(10px); +} + +.archetypePlanetRing { + position: absolute; + top: 0; + left: 0; + mix-blend-mode: screen; +} diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index e9f0b5f2d7..b02e8fb898 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -1,95 +1,123 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { FC } from 'react'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; -import { focusCompose, resetCompose } from '@/mastodon/actions/compose'; +import { useLocation } from 'react-router'; + +import classNames from 'classnames/bind'; + import { closeModal } from '@/mastodon/actions/modal'; -import { Button } from '@/mastodon/components/button'; +import { IconButton } from '@/mastodon/components/icon_button'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { me } from '@/mastodon/initial_state'; -import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Archetype, archetypeNames } from './archetype'; +import { Archetype } from './archetype'; import { Followers } from './followers'; import { HighlightedPost } from './highlighted_post'; +import styles from './index.module.scss'; import { MostUsedHashtag } from './most_used_hashtag'; import { NewPosts } from './new_posts'; -const shareMessage = defineMessage({ +const moduleClassNames = classNames.bind(styles); + +export const shareMessage = defineMessage({ id: 'annual_report.summary.share_message', defaultMessage: 'I got the {archetype} archetype!', }); // Share = false when using the embedded version of the report. -export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => { - const currentAccount = useAppSelector((state) => - me ? state.accounts.get(me) : undefined, - ); +export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ + context = 'standalone', +}) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); const report = useAppSelector((state) => state.annualReport.report); + const account = useAppSelector((state) => { + if (me) { + return state.accounts.get(me); + } + if (report?.schema_version === 2) { + return state.accounts.get(report.account_id); + } + return undefined; + }); + + const close = useCallback(() => { + dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); + }, [dispatch]); + + // Close modal when navigating away from within + const { pathname } = useLocation(); + const [initialPathname] = useState(pathname); + useEffect(() => { + if (pathname !== initialPathname) { + close(); + } + }, [pathname, initialPathname, close]); if (!report) { return ; } + const newPostCount = report.data.time_series.reduce( + (sum, item) => sum + item.statuses, + 0, + ); + + const newFollowerCount = report.data.time_series.reduce( + (sum, item) => sum + item.followers, + 0, + ); + + const topHashtag = report.data.top_hashtags[0]; + return ( -
-
+
+

-

-

- -

+

+ {account &&

@{account.acct}

} + {context === 'modal' && ( + + )}
-
- +
- + {!!newFollowerCount && } + {!!newPostCount && } + {topHashtag && } +
+ - - - {share && }
); }; - -const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const handleShareClick = useCallback(() => { - // Generate the share message. - const archetypeName = intl.formatMessage( - archetypeNames[report.data.archetype], - ); - const shareLines = [ - intl.formatMessage(shareMessage, { - archetype: archetypeName, - }), - ]; - // Share URL is only available for schema version 2. - if (report.schema_version === 2 && report.share_url) { - shareLines.push(report.share_url); - } - shareLines.push(`#Wrapstodon${report.year}`); - - // Reset the composer and focus it with the share message, then close the modal. - dispatch(resetCompose()); - dispatch(focusCompose(shareLines.join('\n\n'))); - dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); - }, [report, intl, dispatch]); - - return + )} + {isRevealed && canShare && } ); }; diff --git a/app/javascript/flavours/glitch/features/annual_report/followers.tsx b/app/javascript/flavours/glitch/features/annual_report/followers.tsx index e5238705d7..b0f2216bc5 100644 --- a/app/javascript/flavours/glitch/features/annual_report/followers.tsx +++ b/app/javascript/flavours/glitch/features/annual_report/followers.tsx @@ -1,68 +1,24 @@ import { FormattedMessage, FormattedNumber } from 'react-intl'; -import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import classNames from 'classnames'; -import { ShortNumber } from 'flavours/glitch/components/short_number'; -import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report'; +import styles from './index.module.scss'; export const Followers: React.FC<{ - data: TimeSeriesMonth[]; - total?: number; -}> = ({ data, total }) => { - const change = data.reduce((sum, item) => sum + item.followers, 0); - - const cumulativeGraph = data.reduce( - (newData, item) => [ - ...newData, - item.followers + (newData[newData.length - 1] ?? 0), - ], - [0], - ); - + count: number; +}> = ({ count }) => { return ( -
- - - - - - - - - +
+
+ +
- - - -
-
- {change > -1 ? '+' : '-'} - -
- -
- - - -
- }} - /> -
-
+
+
); diff --git a/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx b/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx index 6d23e5deb6..61fa365e72 100644 --- a/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx +++ b/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx @@ -1,106 +1,77 @@ /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, - @typescript-eslint/no-unsafe-assignment */ - -import { useCallback } from 'react'; + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call */ import { FormattedMessage } from 'react-intl'; -import { DisplayName } from '@/flavours/glitch/components/display_name'; -import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses'; -import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status'; -import { me } from 'flavours/glitch/initial_state'; +import classNames from 'classnames'; + +import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; import type { TopStatuses } from 'flavours/glitch/models/annual_report'; -import { - makeGetStatus, - makeGetPictureInPicture, -} from 'flavours/glitch/selectors'; -import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { useAppSelector } from 'flavours/glitch/store'; + +import styles from './index.module.scss'; const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any; -const getPictureInPicture = makeGetPictureInPicture() as unknown as ( - arg0: any, - arg1: any, -) => any; export const HighlightedPost: React.FC<{ data: TopStatuses; }> = ({ data }) => { - let statusId, label; + const { by_reblogs, by_favourites, by_replies } = data; - if (data.by_reblogs) { - statusId = data.by_reblogs; - label = ( - - ); - } else if (data.by_favourites) { - statusId = data.by_favourites; - label = ( - - ); - } else { - statusId = data.by_replies; - label = ( - - ); - } + const statusId = by_reblogs || by_favourites || by_replies; - const dispatch = useAppDispatch(); - const domain = useAppSelector((state) => state.meta.get('domain')); const status = useAppSelector((state) => statusId ? getStatus(state, { id: statusId }) : undefined, ); - const pictureInPicture = useAppSelector((state) => - statusId ? getPictureInPicture(state, { id: statusId }) : undefined, - ); - const account = useAppSelector((state) => - me ? state.accounts.get(me) : undefined, - ); - - const handleToggleHidden = useCallback(() => { - dispatch(toggleStatusSpoilers(statusId)); - }, [dispatch, statusId]); if (!status) { - return ( -
+ return
; + } + + let label; + if (by_reblogs) { + label = ( + + ); + } else if (by_favourites) { + label = ( + + ); + } else { + label = ( + ); } - const displayName = ( - - - , - }} - /> - - {label} - - ); - return ( -
- +
+
+

+ +

+

{label}

+
+ +
); }; diff --git a/app/javascript/flavours/glitch/features/annual_report/index.module.scss b/app/javascript/flavours/glitch/features/annual_report/index.module.scss new file mode 100644 index 0000000000..0258f9c798 --- /dev/null +++ b/app/javascript/flavours/glitch/features/annual_report/index.module.scss @@ -0,0 +1,283 @@ +.modalWrapper { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 40px; + overflow-y: auto; + pointer-events: none; + scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary); + + .loading-indicator .circular-progress { + color: var(--lime); + } +} + +.closeButton { + position: absolute; + top: 24px; + right: 24px; + padding: 8px; + border-radius: 100%; + + --default-icon-color: var(--color-bg-primary); + --default-bg-color: var(--color-text-primary); + --hover-icon-color: var(--color-bg-primary); + --hover-bg-color: var(--color-text-primary); +} + +.wrapper { + position: relative; + max-width: 600px; + padding: 24px; + contain: layout; + flex: 0 0 auto; + pointer-events: auto; + color: var(--color-text-primary); + background: var(--color-bg-primary); + background: + radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%), + radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%), + radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%), + radial-gradient(at 16% 95%, #1e948299 0, transparent 50%), + radial-gradient(at 80% 91%, #16dae499 0, transparent 50%) + var(--color-bg-primary); + border-radius: 40px; + + @media (width < 600px) { + padding: 12px; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: inherit; + border-radius: inherit; + filter: blur(20px); + } +} + +.header { + margin-bottom: 18px; + text-align: center; + + h1 { + font-family: monospace; + text-transform: uppercase; + letter-spacing: 0.15em; + font-size: 30px; + font-weight: 600; + line-height: 1.5; + margin-bottom: 8px; + } + + p { + font-size: 14px; + line-height: 1.5; + } +} + +.stack { + --grid-spacing: 12px; + + display: grid; + gap: var(--grid-spacing); +} + +.box { + position: relative; + padding: 16px; + border-radius: 16px; + background: rgb(from var(--color-bg-primary) r g b / 60%); + box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%); + + &::before, + &::after { + content: ''; + position: absolute; + inset-inline: 0; + display: block; + height: 1px; + background-image: linear-gradient( + to right, + transparent, + white, + transparent + ); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } +} + +.content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: 16px; + font-size: 14px; + text-align: center; + text-wrap: balance; + + &.comfortable { + gap: 12px; + } +} + +.title { + text-transform: uppercase; + color: #c2c8ff; + font-weight: 500; +} + +.statLarge { + font-size: 24px; + font-weight: 500; + overflow-wrap: break-word; +} + +.statExtraLarge { + font-size: 32px; + font-weight: 500; + line-height: 1; + overflow-wrap: break-word; +} + +.mostBoostedPost { + padding: 0; + padding-top: 8px; + overflow: hidden; +} + +.statsGrid { + display: grid; + gap: var(--grid-spacing); + grid-template-columns: 1fr 2fr; + grid-template-areas: + 'followers hashtag' + 'new-posts hashtag'; + + @media (width < 680px) { + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'followers new-posts' + 'hashtag hashtag'; + } + + &:empty { + display: none; + } + + &.onlyHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'hashtag'; + } + + &.noHashtag { + grid-template-columns: 1fr 1fr; + grid-template-areas: 'followers new-posts'; + } + + &.singleNumber { + grid-template-columns: 1fr 2fr; + grid-template-areas: 'number hashtag'; + + @media (width < 680px) { + grid-template-areas: + 'number number' + 'hashtag hashtag'; + } + } + + &.singleNumber.noHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'number'; + } +} + +.followers { + grid-area: followers; + + .singleNumber & { + grid-area: number; + } +} + +.newPosts { + grid-area: new-posts; + + .singleNumber & { + grid-area: number; + } +} + +.mostUsedHashtag { + grid-area: hashtag; + padding-block: 24px; +} + +.archetype { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.archetypeArtboard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + width: 180px; + padding-top: 40px; +} + +.archetypeAvatar { + position: absolute; + top: 7px; + left: 4px; + border-radius: 100%; + overflow: hidden; +} + +.archetypeIllustrationWrapper { + position: relative; + width: 92px; + aspect-ratio: 1; + overflow: hidden; + border-radius: 100%; + + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset -10px -4px 15px #00000080; + } +} + +.archetypeIllustration { + width: 100%; +} + +.blurredImage { + filter: blur(10px); +} + +.archetypePlanetRing { + position: absolute; + top: 0; + left: 0; + mix-blend-mode: screen; +} diff --git a/app/javascript/flavours/glitch/features/annual_report/index.tsx b/app/javascript/flavours/glitch/features/annual_report/index.tsx index 954c45bbda..b759610b46 100644 --- a/app/javascript/flavours/glitch/features/annual_report/index.tsx +++ b/app/javascript/flavours/glitch/features/annual_report/index.tsx @@ -1,95 +1,123 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { FC } from 'react'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; -import { focusCompose, resetCompose } from '@/flavours/glitch/actions/compose'; +import { useLocation } from 'react-router'; + +import classNames from 'classnames/bind'; + import { closeModal } from '@/flavours/glitch/actions/modal'; -import { Button } from '@/flavours/glitch/components/button'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; import { me } from '@/flavours/glitch/initial_state'; -import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Archetype, archetypeNames } from './archetype'; +import { Archetype } from './archetype'; import { Followers } from './followers'; import { HighlightedPost } from './highlighted_post'; +import styles from './index.module.scss'; import { MostUsedHashtag } from './most_used_hashtag'; import { NewPosts } from './new_posts'; -const shareMessage = defineMessage({ +const moduleClassNames = classNames.bind(styles); + +export const shareMessage = defineMessage({ id: 'annual_report.summary.share_message', defaultMessage: 'I got the {archetype} archetype!', }); // Share = false when using the embedded version of the report. -export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => { - const currentAccount = useAppSelector((state) => - me ? state.accounts.get(me) : undefined, - ); +export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ + context = 'standalone', +}) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); const report = useAppSelector((state) => state.annualReport.report); + const account = useAppSelector((state) => { + if (me) { + return state.accounts.get(me); + } + if (report?.schema_version === 2) { + return state.accounts.get(report.account_id); + } + return undefined; + }); + + const close = useCallback(() => { + dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); + }, [dispatch]); + + // Close modal when navigating away from within + const { pathname } = useLocation(); + const [initialPathname] = useState(pathname); + useEffect(() => { + if (pathname !== initialPathname) { + close(); + } + }, [pathname, initialPathname, close]); if (!report) { return ; } + const newPostCount = report.data.time_series.reduce( + (sum, item) => sum + item.statuses, + 0, + ); + + const newFollowerCount = report.data.time_series.reduce( + (sum, item) => sum + item.followers, + 0, + ); + + const topHashtag = report.data.top_hashtags[0]; + return ( -
-
+
+

-

-

- -

+ + {account &&

@{account.acct}

} + {context === 'modal' && ( + + )}
-
- +
- + {!!newFollowerCount && } + {!!newPostCount && } + {topHashtag && } +
+ - - - {share && }
); }; - -const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const handleShareClick = useCallback(() => { - // Generate the share message. - const archetypeName = intl.formatMessage( - archetypeNames[report.data.archetype], - ); - const shareLines = [ - intl.formatMessage(shareMessage, { - archetype: archetypeName, - }), - ]; - // Share URL is only available for schema version 2. - if (report.schema_version === 2 && report.share_url) { - shareLines.push(report.share_url); - } - shareLines.push(`#Wrapstodon${report.year}`); - - // Reset the composer and focus it with the share message, then close the modal. - dispatch(resetCompose()); - dispatch(focusCompose(shareLines.join('\n\n'))); - dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); - }, [report, intl, dispatch]); - - return