diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 245b25a934..d2136a2de7 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -9,7 +9,44 @@ permissions: packages: write jobs: + check-latest-stable: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.check.outputs.is_latest_stable }} + steps: + # Repository needs to be cloned to list branches + - name: Clone repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check latest stable + shell: bash + id: check + run: | + ref="${GITHUB_REF#refs/tags/}" + + if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then + current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + else + echo "tag $ref is not semver" + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \ + | sed -E 's#^origin/stable-##' \ + | sort -Vr \ + | head -n1) + + if [[ "$current" == "$latest" ]]; then + echo "is_latest_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + fi + build-image: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile @@ -21,13 +58,14 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} secrets: inherit build-image-streaming: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile @@ -39,7 +77,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} 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; - } diff --git a/CHANGELOG.md b/CHANGELOG.md index 45bb26b514..399b2fe084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [4.5.3] - 2025-12-08 + +### Security + +- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8)) + +### Fixed + +- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire) +- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima) +- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire) +- Fix creation of duplicate conversations (#37108 by @oneiros) +- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima) +- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire) +- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire) +- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion) +- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire) +- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire) +- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire) + ## [4.5.2] - 2025-11-20 ### Changed diff --git a/Gemfile.lock b/Gemfile.lock index ada28236f6..8a94dd6075 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) @@ -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) @@ -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) @@ -855,7 +855,7 @@ GEM unicode-display_width (>= 1.1.1, < 4) terrapin (1.1.1) climate_control - test-prof (1.4.4) + test-prof (1.5.0) thor (1.4.0) tilt (2.6.1) timeout (0.4.3) diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb index e875517b02..4dcddb88e4 100644 --- a/app/controllers/activitypub/likes_controller.rb +++ b/app/controllers/activitypub/likes_controller.rb @@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb index f4a1505550..ff4a76df34 100644 --- a/app/controllers/activitypub/quote_authorizations_controller.rb +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -24,7 +24,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController return not_found unless @quote.status.present? && @quote.quoted_status.present? authorize @quote.quoted_status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 1959f50d67..a857ba03fa 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb index 2d1e389885..3733dfbd6f 100644 --- a/app/controllers/activitypub/shares_controller.rb +++ b/app/controllers/activitypub/shares_controller.rb @@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 2833687a38..659e52bac4 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController def set_poll @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index b4c25476e8..bf30c17857 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController def set_poll @poll = Poll.find(params[:id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/base_controller.rb b/app/controllers/api/v1/statuses/base_controller.rb index 3f56b68bcf..0c4c49a2c3 100644 --- a/app/controllers/api/v1/statuses/base_controller.rb +++ b/app/controllers/api/v1/statuses/base_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController def set_status @status = Status.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index 109b12f467..b4b976ac3c 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController bookmark&.destroy! render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index dbc75a0364..17eeccdbe7 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) render json: @status, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index 971b054c54..6a5788fca3 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } }) render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end @@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController def set_reblog @reblog = Status.find(params[:status_id]) authorize @reblog, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index fd7757f2e8..ec6b93e408 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -146,7 +146,7 @@ class Api::V1::StatusesController < Api::BaseController def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index e385822c42..d0c4e0f3f0 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -3,20 +3,34 @@ class Api::V1Alpha::CollectionsController < Api::BaseController include Authorization + DEFAULT_COLLECTIONS_LIMIT = 40 + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 end before_action :check_feature_enabled + before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] before_action :require_user!, only: [:create, :update, :destroy] + before_action :set_account, only: [:index] + before_action :set_collections, only: [:index] before_action :set_collection, only: [:show, :update, :destroy] + after_action :insert_pagination_headers, only: [:index] + after_action :verify_authorized + def index + cache_if_unauthenticated! + authorize Collection, :index? + + render json: @collections, each_serializer: REST::BaseCollectionSerializer + end + def show cache_if_unauthenticated! authorize @collection, :show? @@ -50,6 +64,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController private + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collections + @collections = @account.collections + .with_tag + .order(created_at: :desc) + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + end + def set_collection @collection = Collection.find(params[:id]) end @@ -65,4 +91,24 @@ class Api::V1Alpha::CollectionsController < Api::BaseController def check_feature_enabled raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? end + + def next_path + return unless records_continue? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def prev_path + return if offset_param.zero? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def records_continue? + ((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size + end + + def offset_param + params[:offset].to_i + end end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index f82c1c50d7..fba56b4058 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 99eed018b0..03cad3e317 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController def set_resource @resource = located_resource authorize(@resource, :show?) if @resource.is_a?(Status) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 9d10468e69..0590ea4027 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -34,7 +34,7 @@ class MediaController < ApplicationController def verify_permitted_status! authorize @media_attachment.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index af6bebf36f..e673faca04 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -62,7 +62,7 @@ class StatusesController < ApplicationController def set_status @status = @account.statuses.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end 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 e1eebcce57..7a74e18d52 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -2,13 +2,11 @@ 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'; +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'; @@ -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)); @@ -43,7 +48,7 @@ function loaded() { - + , diff --git a/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt b/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt new file mode 100644 index 0000000000..63c1c98e1e --- /dev/null +++ b/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt @@ -0,0 +1,100 @@ +Below you'll find the original License file for the Silkscreen font. +The file used on Mastodon is a custom file subset to only include the +characters "Wrapstodon 0123456789" using the Font Squirrel Font-face Generator +(https://www.fontsquirrel.com/tools/webfont-generator) + +----------------------------------------------------------- + +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 b/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 new file mode 100644 index 0000000000..3b7ba43e9c Binary files /dev/null and b/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 differ diff --git a/app/javascript/images/archetypes/space_elements.png b/app/javascript/images/archetypes/space_elements.png new file mode 100644 index 0000000000..8b83506b8e Binary files /dev/null and b/app/javascript/images/archetypes/space_elements.png differ 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/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index f1d8dd9a97..056e7d7b23 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,12 +1,13 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import api, { getLinks } from 'mastodon/api'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; -import {timelineDelete} from './timelines_typed'; +import { timelineDelete } from './timelines_typed'; export { disconnectTimeline } from './timelines_typed'; @@ -24,9 +25,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; export const TIMELINE_INSERT = 'TIMELINE_INSERT'; +// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; export const TIMELINE_GAP = null; +export const TIMELINE_NON_STATUS_MARKERS = [ + TIMELINE_GAP, + TIMELINE_SUGGESTIONS, + TIMELINE_WRAPSTODON, +]; + export const loadPending = timeline => ({ type: TIMELINE_LOAD_PENDING, timeline, @@ -124,6 +132,7 @@ export function expandTimeline(timelineId, path, params = {}) { if (timelineId === 'home') { dispatch(submitMarkers()); + dispatch(reinsertAnnualReport()) } } catch(error) { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index 07d82b2f01..e846882660 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import { TIMELINE_NON_STATUS_MARKERS } from './timelines'; + +export function isNonStatusId(value: unknown) { + return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); +} + export const disconnectTimeline = createAction( 'timeline/disconnect', ({ timeline }: { timeline: string }) => ({ 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 ( -
+

; + +export default meta; + +type Story = StoryObj; + +export const Standalone: Story = { + args: { + context: 'standalone', + }, +}; + +export const InModal: Story = { + args: { + context: 'modal', + }, +}; + +export const ArchetypeOracle: Story = { + ...InModal, + parameters: { + state: { + annualReport: annualReportFactory({ + archetype: 'oracle', + top_hashtag: SAMPLE_HASHTAG, + }), + }, + }, +}; + +export const NoHashtag: Story = { + ...InModal, + parameters: { + state: { + annualReport: annualReportFactory({ + archetype: 'booster', + }), + }, + }, +}; + +export const NoNewPosts: Story = { + ...InModal, + parameters: { + state: { + annualReport: annualReportFactory({ + archetype: 'pollster', + top_hashtag: SAMPLE_HASHTAG, + without_posts: true, + }), + }, + }, +}; + +export const NoNewPostsNoHashtag: Story = { + ...InModal, + parameters: { + state: { + annualReport: annualReportFactory({ + archetype: 'replier', + without_posts: true, + }), + }, + }, +}; diff --git a/app/javascript/mastodon/features/annual_report/archetype.tsx b/app/javascript/mastodon/features/annual_report/archetype.tsx index 0c416c30c2..660a1cf29d 100644 --- a/app/javascript/mastodon/features/annual_report/archetype.tsx +++ b/app/javascript/mastodon/features/annual_report/archetype.tsx @@ -1,65 +1,214 @@ -import { defineMessages, useIntl } from 'react-intl'; +import { useCallback, useRef, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; import booster from '@/images/archetypes/booster.png'; import lurker from '@/images/archetypes/lurker.png'; import oracle from '@/images/archetypes/oracle.png'; import pollster from '@/images/archetypes/pollster.png'; import replier from '@/images/archetypes/replier.png'; -import type { Archetype as ArchetypeData } from '@/mastodon/models/annual_report'; +import space_elements from '@/images/archetypes/space_elements.png'; +import { Avatar } from '@/mastodon/components/avatar'; +import { Button } from '@/mastodon/components/button'; +import type { Account } from '@/mastodon/models/account'; +import type { + AnnualReport, + Archetype as ArchetypeData, +} from '@/mastodon/models/annual_report'; + +import styles from './index.module.scss'; +import { ShareButton } from './share_button'; export const archetypeNames = defineMessages({ 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; + context: 'modal' | 'standalone'; +}> = ({ report, account, context }) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const isSelfView = context === 'modal'; + + 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 && isSelfView && }
); }; 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..2ff8597aa2 100644 --- a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx +++ b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx @@ -1,102 +1,78 @@ /* 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; + context: 'modal' | 'standalone'; +}> = ({ data, context }) => { + 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 ( -
- +
+
+

+ +

+ {context === 'modal' &&

{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..95ebb72729 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/index.module.scss @@ -0,0 +1,318 @@ +$mobile-breakpoint: 540px; + +@font-face { + font-family: silkscreen-wrapstodon; + src: url('@/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2') + format('woff2'); + font-weight: normal; + font-display: swap; + font-style: normal; +} + +.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); + + @media (width < $mobile-breakpoint) { + padding-inline: 10px; + } + + .loading-indicator .circular-progress { + color: var(--lime); + } +} + +.closeButton { + --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); + --corner-distance: 18px; + + position: absolute; + top: var(--corner-distance); + right: var(--corner-distance); + padding: 8px; + border-radius: 100%; + + @media (width < $mobile-breakpoint) { + --corner-distance: 16px; + + padding: 4px; + } +} + +.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 < $mobile-breakpoint) { + padding-inline: 12px; + padding-bottom: 12px; + border-radius: 28px; + } + + &::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: silkscreen-wrapstodon, monospace; + font-size: 28px; + line-height: 1; + margin-bottom: 8px; + padding-inline: 40px; // Prevent overlap with close button + + @media (width < $mobile-breakpoint) { + font-size: 22px; + margin-bottom: 4px; + } + } + + 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; + + @media (width < $mobile-breakpoint) { + font-size: 24px; + } +} + +.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 < $mobile-breakpoint) { + 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 < $mobile-breakpoint) { + 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; + + p { + max-width: 460px; + } +} + +.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..91fd02c7a7 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -1,95 +1,117 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { FC } from 'react'; -import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessage, useIntl } from 'react-intl'; + +import { useLocation } from 'react-router'; + +import classNames from 'classnames/bind'; -import { focusCompose, resetCompose } from '@/mastodon/actions/compose'; 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 = + context === 'modal' && + report.data.time_series.reduce((sum, item) => sum + item.followers, 0); + + const topHashtag = report.data.top_hashtags[0]; + return ( -
-
-

- +
+

Wrapstodon {report.year}

+ {account &&

@{account.acct}

} + {context === 'modal' && ( + -

-

- -

+ )}
-
- - - - - - {share && } +
+ +
+ {!!newFollowerCount && } + {!!newPostCount && } + {topHashtag && ( + + )} +
+
); }; - -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