diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index 7c33d3bfa2..e385822c42 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -1,33 +1,65 @@ # frozen_string_literal: true class Api::V1Alpha::CollectionsController < Api::BaseController + include Authorization + 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 -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create] + before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] - before_action :require_user!, only: [:create] + before_action :require_user!, only: [:create, :update, :destroy] + + before_action :set_collection, only: [:show, :update, :destroy] + + after_action :verify_authorized def show cache_if_unauthenticated! - @collection = Collection.find(params[:id]) + authorize @collection, :show? render json: @collection, serializer: REST::CollectionSerializer end def create - @collection = CreateCollectionService.new.call(collection_params, current_user.account) + authorize Collection, :create? + + @collection = CreateCollectionService.new.call(collection_creation_params, current_user.account) render json: @collection, serializer: REST::CollectionSerializer end + def update + authorize @collection, :update? + + @collection.update!(collection_update_params) # TODO: Create a service for this to federate changes + + render json: @collection, serializer: REST::CollectionSerializer + end + + def destroy + authorize @collection, :destroy? + + @collection.destroy + + head 200 + end + private - def collection_params - params.permit(:name, :description, :sensitive, :discoverable, :tag, account_ids: []) + def set_collection + @collection = Collection.find(params[:id]) + end + + def collection_creation_params + params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: []) + end + + def collection_update_params + params.permit(:name, :description, :sensitive, :discoverable, :tag_name) end def check_feature_enabled diff --git a/app/helpers/wrapstodon_helper.rb b/app/helpers/wrapstodon_helper.rb new file mode 100644 index 0000000000..da3b0d6fad --- /dev/null +++ b/app/helpers/wrapstodon_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module WrapstodonHelper + def render_wrapstodon_share_data(report) + json = ActiveModelSerializers::SerializableResource.new( + AnnualReportsPresenter.new([report]), + serializer: REST::AnnualReportsSerializer, + scope: nil, + scope_name: :current_user + ).to_json + + # rubocop:disable Rails/OutputSafety + content_tag(:script, json_escape(json).html_safe, type: 'application/json', id: 'wrapstodon-data') + # rubocop:enable Rails/OutputSafety + end +end diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx new file mode 100644 index 0000000000..e1eebcce57 --- /dev/null +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -0,0 +1,58 @@ +import { createRoot } from 'react-dom/client'; + +import { Provider as ReduxProvider } from 'react-redux'; + +import { + importFetchedAccounts, + importFetchedStatuses, +} from '@/mastodon/actions/importer'; +import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; +import { Router } from '@/mastodon/components/router'; +import { WrapstodonShare } from '@/mastodon/features/annual_report/share'; +import { IntlProvider, loadLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { setReport } from '@/mastodon/reducers/slices/annual_report'; +import { store } from '@/mastodon/store'; + +function loaded() { + const mountNode = document.getElementById('wrapstodon'); + if (!mountNode) { + throw new Error('Mount node not found'); + } + const propsNode = document.getElementById('wrapstodon-data'); + if (!propsNode) { + throw new Error('Initial state prop not found'); + } + + const initialState = JSON.parse( + propsNode.textContent, + ) as ApiAnnualReportResponse; + + const report = initialState.annual_reports[0]; + if (!report) { + throw new Error('Initial state report not found'); + } + store.dispatch(importFetchedAccounts(initialState.accounts)); + store.dispatch(importFetchedStatuses(initialState.statuses)); + + store.dispatch(setReport(report)); + + const root = createRoot(mountNode); + root.render( + + + + + + + , + ); +} + +loadPolyfills() + .then(loadLocale) + .then(() => ready(loaded)) + .catch((err: unknown) => { + console.error(err); + }); diff --git a/app/javascript/mastodon/components/logo.tsx b/app/javascript/mastodon/components/logo.tsx index fe9680d0e3..c0ed4d78a7 100644 --- a/app/javascript/mastodon/components/logo.tsx +++ b/app/javascript/mastodon/components/logo.tsx @@ -1,3 +1,5 @@ +import classNames from 'classnames'; + import logo from '@/images/logo.svg'; export const WordmarkLogo: React.FC = () => ( @@ -7,8 +9,12 @@ export const WordmarkLogo: React.FC = () => ( ); -export const IconLogo: React.FC = () => ( - +export const IconLogo: React.FC<{ className?: string }> = ({ className }) => ( + Mastodon diff --git a/app/javascript/mastodon/features/annual_report/archetype.tsx b/app/javascript/mastodon/features/annual_report/archetype.tsx index fffbc1803e..0c416c30c2 100644 --- a/app/javascript/mastodon/features/annual_report/archetype.tsx +++ b/app/javascript/mastodon/features/annual_report/archetype.tsx @@ -1,68 +1,64 @@ -import { FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; 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 type { Archetype as ArchetypeData } from '@/mastodon/models/annual_report'; + +export const archetypeNames = defineMessages({ + booster: { + id: 'annual_report.summary.archetype.booster', + defaultMessage: 'The cool-hunter', + }, + replier: { + id: 'annual_report.summary.archetype.replier', + defaultMessage: 'The social butterfly', + }, + pollster: { + id: 'annual_report.summary.archetype.pollster', + defaultMessage: 'The pollster', + }, + lurker: { + id: 'annual_report.summary.archetype.lurker', + defaultMessage: 'The lurker', + }, + oracle: { + id: 'annual_report.summary.archetype.oracle', + defaultMessage: 'The oracle', + }, +}); export const Archetype: React.FC<{ data: ArchetypeData; }> = ({ data }) => { - let illustration, label; + const intl = useIntl(); + let illustration; switch (data) { case 'booster': illustration = booster; - label = ( - - ); break; case 'replier': illustration = replier; - label = ( - - ); break; case 'pollster': illustration = pollster; - label = ( - - ); break; case 'lurker': illustration = lurker; - label = ( - - ); break; case 'oracle': illustration = oracle; - label = ( - - ); break; } return (
-
{label}
+
+ {intl.formatMessage(archetypeNames[data])} +
); diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index 9c2c9d71c0..e9f0b5f2d7 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -1,68 +1,38 @@ -import { useState, useEffect } from 'react'; +import { useCallback } from 'react'; +import type { FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; -import { - importFetchedStatuses, - importFetchedAccounts, -} from 'mastodon/actions/importer'; -import { apiRequestGet, apiRequestPost } from 'mastodon/api'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { me } from 'mastodon/initial_state'; -import type { Account } from 'mastodon/models/account'; -import type { AnnualReport as AnnualReportData } from 'mastodon/models/annual_report'; -import type { Status } from 'mastodon/models/status'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { focusCompose, resetCompose } from '@/mastodon/actions/compose'; +import { closeModal } from '@/mastodon/actions/modal'; +import { Button } from '@/mastodon/components/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 { Archetype } from './archetype'; +import { Archetype, archetypeNames } from './archetype'; import { Followers } from './followers'; import { HighlightedPost } from './highlighted_post'; import { MostUsedHashtag } from './most_used_hashtag'; import { NewPosts } from './new_posts'; -import { Percentile } from './percentile'; -interface AnnualReportResponse { - annual_reports: AnnualReportData[]; - accounts: Account[]; - statuses: Status[]; -} +const shareMessage = defineMessage({ + id: 'annual_report.summary.share_message', + defaultMessage: 'I got the {archetype} archetype!', +}); -export const AnnualReport: React.FC<{ - year: string; -}> = ({ year }) => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); +// 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, ); - const dispatch = useAppDispatch(); + const report = useAppSelector((state) => state.annualReport.report); - useEffect(() => { - apiRequestGet(`v1/annual_reports/${year}`) - .then((data) => { - dispatch(importFetchedStatuses(data.statuses)); - dispatch(importFetchedAccounts(data.accounts)); - - setResponse(data); - setLoading(false); - - return apiRequestPost(`v1/annual_reports/${year}/read`); - }) - .catch(() => { - setLoading(false); - }); - }, [dispatch, year, setResponse, setLoading]); - - if (loading) { + if (!report) { return ; } - const report = response?.annual_reports[0]; - - if (!report || report.schema_version !== 1) { - return null; - } - return (
@@ -89,9 +59,37 @@ export const AnnualReport: React.FC<{ total={currentAccount?.followers_count} /> - + {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