Compare commits

...

58 Commits

Author SHA1 Message Date
Claire
d3afd087b5 Merge pull request #3314 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 4af8e83c8a
2025-12-13 00:55:28 +01:00
Claire
235af71f85 [Glitch] Fix wrapstodon not falling back to username when display name is not set
Port 4af8e83c8a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:53 +01:00
diondiondion
6734fd206e [Glitch] Change Emoji in Wrapstodon footer 🐘
Port 861202fd08 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:53 +01:00
diondiondion
cb1d1e289e [Glitch] Add secondary Wrapstodon share button
Port 6821b70796 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:53 +01:00
diondiondion
acf583d374 [Glitch] Remember revealed archetype on future Wrapstodon visits
Port b72b507584 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:53 +01:00
diondiondion
8fad8681ab [Glitch] Convert Settings class to TS
Port 8748f0812d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:53 +01:00
Echo
e7c383251b [Glitch] Wrapstodon: Add nav modal
Port e206b0d0de to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:59:52 +01:00
diondiondion
febd6241bf [Glitch] Change Wrapstodon 'About' link to point to joinmastodon.org
Port 571c93c563 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:48:32 +01:00
Echo
aa45a5fa83 [Glitch] Wrapstodon: Allow dismissing banner
Port 10f232ca08 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-12 17:48:12 +01:00
Claire
6503287c2d Merge commit '4af8e83c8a236265c46a1b984fa5dbd3a7c73dfe' into glitch-soc/merge-upstream 2025-12-12 17:45:26 +01:00
Claire
4af8e83c8a Fix wrapstodon not falling back to username when display name is not set (#37229) 2025-12-12 16:30:22 +00:00
diondiondion
861202fd08 Change Emoji in Wrapstodon footer 🐘 (#37226) 2025-12-12 14:31:21 +00:00
diondiondion
6821b70796 Add secondary Wrapstodon share button (#37224) 2025-12-12 13:39:56 +00:00
David Roetzel
3cc4b59b41 First draft of API to add items to a collection (#37222) 2025-12-12 13:09:55 +00:00
Claire
1e67567d8f Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221) 2025-12-12 12:42:43 +00:00
diondiondion
b72b507584 Remember revealed archetype on future Wrapstodon visits (#37219) 2025-12-12 11:03:00 +00:00
diondiondion
8748f0812d Convert Settings class to TS (#37218) 2025-12-12 10:20:32 +00:00
Echo
e206b0d0de Wrapstodon: Add nav modal (#37210) 2025-12-12 10:11:47 +00:00
diondiondion
571c93c563 Change Wrapstodon 'About' link to point to joinmastodon.org (#37216) 2025-12-12 09:43:34 +00:00
Echo
10f232ca08 Wrapstodon: Allow dismissing banner (#37202) 2025-12-12 09:40:45 +00:00
Claire
88c0f52e99 Merge pull request #3313 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d730f6b0c5
2025-12-11 19:23:18 +01:00
Claire
a56b739c68 [Glitch] Fix wrapstodon modal closing on any click
Port dfbf908870 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 19:07:26 +01:00
diondiondion
183a42a5ee [Glitch] Add Wrapstodon footer links
Port c06eb371e6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 19:06:54 +01:00
Claire
303a5478af Merge commit 'dfbf908870fcde76396ebccfb3d71ee1a06ffe82' into glitch-soc/merge-upstream
Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict because of glitch-soc's theming system.
  Applied upstream's changes.
2025-12-11 19:05:32 +01:00
Claire
dfbf908870 Fix wrapstodon modal closing on any click (#37209) 2025-12-11 17:49:26 +00:00
diondiondion
aa067370d8 [Glitch] Fix Wrapstodon modal scrolling not working on iOS
Port 4323963053 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 18:07:59 +01:00
diondiondion
5e0db46b2a [Glitch] Wrapstodon design QA tweaks
Port 5651900b89 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 18:07:34 +01:00
diondiondion
c06eb371e6 Add Wrapstodon footer links (#37207) 2025-12-11 17:06:26 +00:00
Claire
53617cef5a Merge commit 'd730f6b0c5cfb18894d1a9e34d0aa2556dda3c62' into glitch-soc/merge-upstream 2025-12-11 18:05:28 +01:00
Emelia Smith
d730f6b0c5 Add spec for client_credentials being used with /api/v1/apps/verify_credentials (#37195) 2025-12-11 16:40:22 +00:00
Claire
addeb28292 Change wrapstodon 2025 to allow unlisted posts in top statuses (#37206) 2025-12-11 16:35:35 +00:00
Claire
5e3387539e Add image to Wrapstodon OpenGraph banner (#37205) 2025-12-11 16:22:48 +00:00
diondiondion
4323963053 Fix Wrapstodon modal scrolling not working on iOS (#37203) 2025-12-11 14:25:28 +00:00
diondiondion
5651900b89 Wrapstodon design QA tweaks (#37201) 2025-12-11 11:40:53 +00:00
renovate[bot]
d1b996b7e3 Update dependency omniauth-rails_csrf_protection to v2.0.1 (#37199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 11:12:07 +00:00
renovate[bot]
fed26a41fa Update dependency jsdom to v27.3.0 (#37165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:33:47 +00:00
Claire
37d309bcaf Fix Wrapstodon font loading by disabling inlining of fonts in Vite (#37198) 2025-12-11 10:33:15 +00:00
renovate[bot]
d25f672c50 Update dependency active_model_serializers to v0.10.16 (#37167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:52 +00:00
renovate[bot]
15c9088761 Update dependency vite to v7.2.7 (#37156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:38 +00:00
renovate[bot]
da1505a495 Update dependency @vitejs/plugin-react to v5.1.2 (#37155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:30 +00:00
renovate[bot]
d1f690f50c Update dependency stoplight to v5.7.0 (#37151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:25 +00:00
Claire
8b418b84d0 Merge pull request #3312 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d6f2a3ac8d
2025-12-10 22:09:22 +01:00
diondiondion
f817300d8d [Glitch] Implement custom font for Wrapstodon heading
Port c42b9f6996 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:13:17 +01:00
Echo
35a89a0173 [Glitch] Fix issue where Wrapstodon was pushed to the bottom of the feed
Port 76184c998c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:12:23 +01:00
diondiondion
b5721dbd4a [Glitch] Fix Wrapstodon Storybook & other Wrapstodon issues
Port 8137ce87ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:11:08 +01:00
diondiondion
38f623eee7 [Glitch] Minor Wrapstodon tweaks, add stub Storybook page
Port 91500a7f53 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:09:09 +01:00
Claire
17ba99e5de Merge commit 'd6f2a3ac8d61e0828a17f68a6e9094d0f4662f4c' into glitch-soc/merge-upstream
Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict because of glitch-soc's theming change.
  Adapted upstream's changes.
- `docker-compose.yml`:
  Conflict because of container repo name change.
  Adapted upstream's changes.
- `yarn.lock`:
  Conflict because of an additional glitch-soc dependency.
  Updated the dependencies upstream did.
2025-12-10 18:05:44 +01:00
Claire
da2b75bdcd Change build-releases workflow to tag images latest based on latest stable-x.y branch (#37179)
Co-authored-by: emilweth <7402764+emilweth@users.noreply.github.com>
2025-12-10 17:01:25 +00:00
David Roetzel
adf8a3601d Add service to add item to a collection (#37192) 2025-12-10 16:59:21 +00:00
Claire
d6f2a3ac8d Bump version to v4.5.3 (#37166) 2025-12-10 16:42:19 +00:00
diondiondion
c42b9f6996 Implement custom font for Wrapstodon heading (#37193) 2025-12-10 16:26:46 +00:00
Echo
76184c998c Fix issue where Wrapstodon was pushed to the bottom of the feed (#37190) 2025-12-10 15:55:12 +00:00
diondiondion
8137ce87ce Fix Wrapstodon Storybook & other Wrapstodon issues (#37189) 2025-12-10 14:07:25 +00:00
renovate[bot]
37426288d9 Update dependency postcss-preset-env to v10.5.0 (#37132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:39 +00:00
renovate[bot]
801fee7593 Update dependency test-prof to v1.5.0 (#37127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:29 +00:00
Claire
6838497fe8 Add title and description to Opengraph data for Wrapstodon share page (#37188) 2025-12-10 11:27:10 +00:00
Claire
7b8a5d42f1 Remove unused time series details from 2025 annual report (#37187) 2025-12-10 11:02:24 +00:00
diondiondion
91500a7f53 Minor Wrapstodon tweaks, add stub Storybook page (#37186) 2025-12-10 09:05:14 +00:00
114 changed files with 2527 additions and 782 deletions

View File

@@ -9,7 +9,44 @@ permissions:
packages: write packages: write
jobs: 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: build-image:
needs: check-latest-stable
uses: ./.github/workflows/build-container-image.yml uses: ./.github/workflows/build-container-image.yml
with: with:
file_to_build: Dockerfile file_to_build: Dockerfile
@@ -20,13 +57,14 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} latest=${{ needs.check-latest-stable.outputs.latest }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}
secrets: inherit secrets: inherit
build-image-streaming: build-image-streaming:
needs: check-latest-stable
uses: ./.github/workflows/build-container-image.yml uses: ./.github/workflows/build-container-image.yml
with: with:
file_to_build: streaming/Dockerfile file_to_build: streaming/Dockerfile
@@ -37,7 +75,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} latest=${{ needs.check-latest-stable.outputs.latest }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}

View File

@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file. 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 ## [4.5.2] - 2025-11-20
### Changed ### Changed

View File

@@ -53,7 +53,7 @@ GEM
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.15) active_model_serializers (0.10.16)
actionpack (>= 4.1) actionpack (>= 4.1)
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
@@ -481,7 +481,7 @@ GEM
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
omniauth-rails_csrf_protection (2.0.0) omniauth-rails_csrf_protection (2.0.1)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-saml (2.2.4) omniauth-saml (2.2.4)
@@ -839,7 +839,8 @@ GEM
stackprof (0.2.27) stackprof (0.2.27)
starry (0.2.0) starry (0.2.0)
base64 base64
stoplight (5.6.0) stoplight (5.7.0)
concurrent-ruby
zeitwerk zeitwerk
stringio (3.1.8) stringio (3.1.8)
strong_migrations (2.5.1) strong_migrations (2.5.1)
@@ -855,7 +856,7 @@ GEM
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1) terrapin (1.1.1)
climate_control climate_control
test-prof (1.4.4) test-prof (1.5.0)
thor (1.4.0) thor (1.4.0)
tilt (2.6.1) tilt (2.6.1)
timeout (0.4.3) timeout (0.4.3)

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
class Api::V1Alpha::CollectionItemsController < Api::BaseController
include Authorization
before_action :check_feature_enabled
before_action -> { doorkeeper_authorize! :write, :'write:collections' }
before_action :require_user!
before_action :set_collection
before_action :set_account, only: [:create]
after_action :verify_authorized
def create
authorize @collection, :update?
authorize @account, :feature?
@item = AddAccountToCollectionService.new.call(@collection, @account)
render json: @item, serializer: REST::CollectionItemSerializer
end
private
def set_collection
@collection = Collection.find(params[:collection_id])
end
def set_account
return render(json: { error: '`account_id` parameter is missing' }, status: 422) if params[:account_id].blank?
@account = Account.find(params[:account_id])
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
end

View File

@@ -72,10 +72,13 @@ module SignatureVerification
rescue Mastodon::SignatureVerificationError => e rescue Mastodon::SignatureVerificationError => e
fail_with! e.message fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
@signature_verification_failure_code ||= 503
fail_with! "Failed to fetch remote data: #{e.message}" fail_with! "Failed to fetch remote data: #{e.message}"
rescue Mastodon::UnexpectedResponseError rescue Mastodon::UnexpectedResponseError
@signature_verification_failure_code ||= 503
fail_with! 'Failed to fetch remote data (got unexpected reply from server)' fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
rescue Stoplight::Error::RedLight rescue Stoplight::Error::RedLight
@signature_verification_failure_code ||= 503
fail_with! 'Fetching attempt skipped because of recent connection failure' fail_with! 'Fetching attempt skipped because of recent connection failure'
end end

View File

@@ -147,7 +147,7 @@ export const hydrateSearch = createAppAsyncThunk(
'search/hydrate', 'search/hydrate',
(_args, { dispatch, getState }) => { (_args, { dispatch, getState }) => {
const me = getState().meta.get('me') as string; const me = getState().meta.get('me') as string;
const history = searchHistory.get(me) as RecentSearch[] | null; const history = searchHistory.get(me);
if (history !== null) { if (history !== null) {
dispatch(updateSearchHistory(history)); dispatch(updateSearchHistory(history));

View File

@@ -7,7 +7,7 @@ import { toServerSideType } from 'flavours/glitch/utils/filters';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed'; import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed'; export { disconnectTimeline } from './timelines_typed';
@@ -25,9 +25,15 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT'; 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_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null; export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
];
export const loadPending = timeline => ({ export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING, type: TIMELINE_LOAD_PENDING,
timeline, timeline,

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/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( export const disconnectTimeline = createAction(
'timeline/disconnect', 'timeline/disconnect',
({ timeline }: { timeline: string }) => ({ ({ timeline }: { timeline: string }) => ({

View File

@@ -1,12 +1,10 @@
import type { PropsWithChildren } from 'react'; import type { FC, ReactNode } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { bannerSettings } from 'flavours/glitch/settings'; import { useDismissible } from '../hooks/useDismissible';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@@ -16,48 +14,12 @@ const messages = defineMessages({
interface Props { interface Props {
id: string; id: string;
children: ReactNode;
} }
export function useDismissableBannerState({ id }: Props) { export const DismissableBanner: FC<Props> = ({ id, children }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dismissed: boolean = useAppSelector((state) =>
/* eslint-disable-next-line */
state.settings.getIn(['dismissed_banners', id], false),
);
const [isVisible, setIsVisible] = useState(
!bannerSettings.get(id) && !dismissed,
);
const dispatch = useAppDispatch();
const dismiss = useCallback(() => {
setIsVisible(false);
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
// Store legacy localStorage setting on server
if (!isVisible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, isVisible, dismissed]);
return {
wasDismissed: !isVisible,
dismiss,
};
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(id);
id,
});
if (wasDismissed) { if (wasDismissed) {
return null; return null;

View File

@@ -0,0 +1,45 @@
import { useCallback, useRef } from 'react';
export const InterceptStatusClicks: React.FC<{
onPreventedClick: (
clickedArea: 'account' | 'post',
event: React.MouseEvent,
) => void;
children: React.ReactNode;
}> = ({ onPreventedClick, children }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const clickTarget = e.target as Element;
const allowedElementsSelector =
'.video-player, .audio-player, .media-gallery, .content-warning';
const allowedElements = wrapperRef.current?.querySelectorAll(
allowedElementsSelector,
);
const isTargetClickAllowed =
allowedElements &&
Array.from(allowedElements).some((element) => {
return clickTarget === element || element.contains(clickTarget);
});
if (!isTargetClickAllowed) {
e.preventDefault();
e.stopPropagation();
const wasAccountAreaClicked = !!clickTarget.closest(
'a.status__display-name',
);
onPreventedClick(wasAccountAreaClicked ? 'account' : 'post', e);
}
},
[onPreventedClick],
);
return (
<div ref={wrapperRef} onClickCapture={handleClick}>
{children}
</div>
);
};

View File

@@ -6,13 +6,13 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button'; import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon'; import { Icon } from '../icon';
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; const DISMISSIBLE_BANNER_ID = 'notifications/remove_quote_hint';
/** /**
* We don't want to show this hint in the UI more than once, * We don't want to show this hint in the UI more than once,
@@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{
const anchorRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
id: DISMISSABLE_BANNER_ID,
});
const shouldShowHint = !wasDismissed && canShowHint; const shouldShowHint = !wasDismissed && canShowHint;

View File

@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines';
import { RegenerationIndicator } from 'flavours/glitch/components/regeneration_indicator'; import { RegenerationIndicator } from 'flavours/glitch/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'flavours/glitch/features/home_timeline/components/inline_follow_suggestions'; import { InlineFollowSuggestions } from 'flavours/glitch/features/home_timeline/components/inline_follow_suggestions';
import { AnnualReportTimeline } from 'flavours/glitch/features/annual_report/timeline';
import { TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
import { StatusQuoteManager } from '../components/status_quoted'; import { StatusQuoteManager } from '../components/status_quoted';
@@ -68,10 +66,6 @@ export default class StatusList extends ImmutablePureComponent {
return ( return (
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} /> <InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
); );
case TIMELINE_WRAPSTODON:
return (
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
)
case TIMELINE_GAP: case TIMELINE_GAP:
return ( return (
<LoadGap <LoadGap

View File

@@ -1,38 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test'; import { action } from 'storybook/actions';
import type { AnyFunction, OmitValueType } from '@/flavours/glitch/utils/types';
import type { AnnualReportAnnouncementProps } from '.';
import { AnnualReportAnnouncement } from '.'; import { AnnualReportAnnouncement } from '.';
const meta = { type Props = OmitValueType<
title: 'Components/AnnualReportAnnouncement', // We can't use the name 'state' here because it's reserved for overriding Redux state.
component: AnnualReportAnnouncement, Omit<AnnualReportAnnouncementProps, 'state'> & {
args: { reportState: AnnualReportAnnouncementProps['state'];
hasData: false,
isLoading: false,
year: '2025',
onRequestBuild: fn(),
onOpen: fn(),
}, },
} satisfies Meta<typeof AnnualReportAnnouncement>; AnyFunction // Remove any functions, as they can't meaningfully be controlled in Storybook.
>;
const meta = {
title: 'Components/AnnualReport/Announcement',
args: {
reportState: 'eligible',
year: '2025',
},
argTypes: {
reportState: {
control: {
type: 'select',
},
options: ['eligible', 'generating', 'available'],
},
},
render({ reportState, ...args }: Props) {
return (
<AnnualReportAnnouncement
state={reportState}
{...args}
onDismiss={action('dismissed announcement')}
onOpen={action('opened report modal')}
onRequestBuild={action('requested build')}
/>
);
},
} satisfies Meta<Props>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {};
render: (args) => <AnnualReportAnnouncement {...args} />,
};
export const Loading: Story = { export const Loading: Story = {
args: { args: {
isLoading: true, reportState: 'generating',
}, },
render: Default.render,
}; };
export const WithData: Story = { export const WithData: Story = {
args: { args: {
hasData: true, reportState: 'available',
}, },
render: Default.render,
}; };

View File

@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
import { Button } from '@/flavours/glitch/components/button'; import { Button } from '@/flavours/glitch/components/button';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
export const AnnualReportAnnouncement: React.FC<{ export interface AnnualReportAnnouncementProps {
year: string; year: string;
hasData: boolean; state: Exclude<ApiAnnualReportState, 'ineligible'>;
isLoading: boolean;
onRequestBuild: () => void; onRequestBuild: () => void;
onOpen: () => void; onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then.
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { onDismiss: () => void;
}
export const AnnualReportAnnouncement: React.FC<
AnnualReportAnnouncementProps
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
return ( return (
<div className={classNames('theme-dark', styles.wrapper)}> <div className={classNames('theme-dark', styles.wrapper)}>
<h2> <FormattedMessage
<FormattedMessage id='annual_report.announcement.title'
id='annual_report.announcement.title' defaultMessage='Wrapstodon {year} has arrived'
defaultMessage='Wrapstodon {year} has arrived' values={{ year }}
values={{ year }} tagName='h2'
/> />
</h2> <FormattedMessage
<p> id='annual_report.announcement.description'
<FormattedMessage defaultMessage='Discover more about your engagement on Mastodon over the past year.'
id='annual_report.announcement.description' tagName='p'
defaultMessage='Discover more about your engagement on Mastodon over the past year.' />
/> {state === 'available' ? (
</p>
{hasData ? (
<Button onClick={onOpen}> <Button onClick={onOpen}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_view' id='annual_report.announcement.action_view'
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
/> />
</Button> </Button>
) : ( ) : (
<Button loading={isLoading} onClick={onRequestBuild}> <Button loading={state === 'generating'} onClick={onRequestBuild}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_build' id='annual_report.announcement.action_build'
defaultMessage='Build my Wrapstodon' defaultMessage='Build my Wrapstodon'
/> />
</Button> </Button>
)} )}
{state === 'eligible' && (
<Button onClick={onDismiss} plain className={styles.closeButton}>
<FormattedMessage
id='annual_report.announcement.action_dismiss'
defaultMessage='No thanks'
/>
</Button>
)}
</div> </div>
); );
}; };

View File

@@ -15,6 +15,8 @@
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%) radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
var(--color-bg-primary); var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
position: relative;
pointer-events: all;
h2 { h2 {
font-size: 20px; font-size: 20px;
@@ -26,4 +28,15 @@
p { p {
margin-bottom: 20px; margin-bottom: 20px;
} }
.closeButton {
position: absolute;
bottom: 8px;
right: 8px;
margin-inline: 0;
}
:global(.modal-root__modal) & {
border-radius: 16px;
}
} }

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
}),
},
},
};

View File

@@ -12,6 +12,7 @@ import type {
AnnualReport, AnnualReport,
Archetype as ArchetypeData, Archetype as ArchetypeData,
} from '@/flavours/glitch/models/annual_report'; } from '@/flavours/glitch/models/annual_report';
import { wrapstodonSettings } from '@/flavours/glitch/settings';
import booster from '@/images/archetypes/booster.png'; import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png'; import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png'; import oracle from '@/images/archetypes/oracle.png';
@@ -112,15 +113,22 @@ const illustrations = {
export const Archetype: React.FC<{ export const Archetype: React.FC<{
report: AnnualReport; report: AnnualReport;
account?: Account; account?: Account;
canShare: boolean; context: 'modal' | 'standalone';
}> = ({ report, account, canShare }) => { }> = ({ report, account, context }) => {
const intl = useIntl(); const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = account?.id === me; const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView); const [isRevealed, setIsRevealed] = useState(
() =>
!isSelfView ||
(me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true),
);
const reveal = useCallback(() => { const reveal = useCallback(() => {
setIsRevealed(true); setIsRevealed(true);
if (me) {
wrapstodonSettings.set(me, { archetypeRevealed: true });
}
wrapperRef.current?.focus(); wrapperRef.current?.focus();
}, []); }, []);
@@ -129,7 +137,8 @@ export const Archetype: React.FC<{
? archetypeSelfDescriptions ? archetypeSelfDescriptions
: archetypePublicDescriptions; : archetypePublicDescriptions;
const name = account?.display_name; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty
const name = account?.display_name || account?.username;
return ( return (
<div <div
@@ -209,7 +218,7 @@ export const Archetype: React.FC<{
/> />
</Button> </Button>
)} )}
{isRevealed && canShare && <ShareButton report={report} />} {isRevealed && isSelfView && <ShareButton report={report} />}
</div> </div>
); );
}; };

View File

@@ -4,10 +4,14 @@
@typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call */ @typescript-eslint/no-unsafe-call */
import type { ComponentPropsWithoutRef } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { InterceptStatusClicks } from 'flavours/glitch/components/status/intercept_status_clicks';
import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted';
import type { TopStatuses } from 'flavours/glitch/models/annual_report'; import type { TopStatuses } from 'flavours/glitch/models/annual_report';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus } from 'flavours/glitch/selectors';
@@ -19,7 +23,8 @@ const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
export const HighlightedPost: React.FC<{ export const HighlightedPost: React.FC<{
data: TopStatuses; data: TopStatuses;
}> = ({ data }) => { context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data; const { by_reblogs, by_favourites, by_replies } = data;
const statusId = by_reblogs || by_favourites || by_replies; const statusId = by_reblogs || by_favourites || by_replies;
@@ -28,6 +33,24 @@ export const HighlightedPost: React.FC<{
statusId ? getStatus(state, { id: statusId }) : undefined, statusId ? getStatus(state, { id: statusId }) : undefined,
); );
const handleClick = useCallback<
ComponentPropsWithoutRef<typeof InterceptStatusClicks>['onPreventedClick']
>(
(clickedArea) => {
const link: string =
clickedArea === 'account'
? status.getIn(['account', 'url'])
: status.get('url');
if (context === 'standalone') {
window.location.href = link;
} else {
window.open(link, '_blank');
}
},
[status, context],
);
if (!status) { if (!status) {
return <div className={classNames(styles.box, styles.mostBoostedPost)} />; return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
} }
@@ -68,10 +91,12 @@ export const HighlightedPost: React.FC<{
defaultMessage='Most popular post' defaultMessage='Most popular post'
/> />
</h2> </h2>
<p>{label}</p> {context === 'modal' && <p>{label}</p>}
</div> </div>
<StatusQuoteManager showActions={false} id={`${statusId}`} /> <InterceptStatusClicks onPreventedClick={handleClick}>
<StatusQuoteManager showActions={false} id={statusId} />
</InterceptStatusClicks>
</div> </div>
); );
}; };

View File

@@ -1,62 +1,99 @@
$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 { .modalWrapper {
position: absolute; box-sizing: border-box;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%;
padding: 40px; padding: 40px;
overflow-y: auto; overflow-y: auto;
pointer-events: none;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary); scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding: 0;
}
.loading-indicator .circular-progress { .loading-indicator .circular-progress {
color: var(--lime); color: var(--lime);
} }
} }
.closeButton { .closeButton {
position: absolute;
top: 24px;
right: 24px;
padding: 8px;
border-radius: 100%;
--default-icon-color: var(--color-bg-primary); --default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary); --default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary); --hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-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 { .wrapper {
--gradient-strength: 0.4;
box-sizing: border-box;
position: relative; position: relative;
max-width: 600px; max-width: 600px;
padding: 24px; padding: 24px;
padding-top: 40px;
contain: layout; contain: layout;
flex: 0 0 auto; flex: 0 0 auto;
pointer-events: auto; pointer-events: all;
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-primary); background: var(--color-bg-primary);
background: background:
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%), radial-gradient(
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%), at 10% 27%,
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%), rgba(83, 12, 154, var(--gradient-strength)) 0,
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%), transparent 50%
radial-gradient(at 80% 91%, #16dae499 0, transparent 50%) ),
radial-gradient(
at 91% 10%,
rgba(30, 24, 223, var(--gradient-strength)) 0,
transparent 25%
),
radial-gradient(
at 10% 91%,
rgba(22, 218, 228, var(--gradient-strength)) 0,
transparent 40%
),
radial-gradient(
at 75% 87%,
rgba(37, 31, 217, var(--gradient-strength)) 0,
transparent 20%
),
radial-gradient(
at 84% 60%,
rgba(95, 30, 148, var(--gradient-strength)) 0,
transparent 40%
)
var(--color-bg-primary); var(--color-bg-primary);
border-radius: 40px; border-radius: 40px;
@media (width < 600px) { @media (width < $mobile-breakpoint) {
padding: 12px; padding-inline: 12px;
} padding-bottom: 12px;
border-radius: 0;
&::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: inherit;
border-radius: inherit;
filter: blur(20px);
} }
} }
@@ -65,13 +102,16 @@
text-align: center; text-align: center;
h1 { h1 {
font-family: monospace; font-family: silkscreen-wrapstodon, monospace;
text-transform: uppercase; font-size: 28px;
letter-spacing: 0.15em; line-height: 1;
font-size: 30px; margin-bottom: 4px;
font-weight: 600; padding-inline: 40px; // Prevent overlap with close button
line-height: 1.5;
margin-bottom: 8px; @media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
} }
p { p {
@@ -89,7 +129,7 @@
.box { .box {
position: relative; position: relative;
padding: 16px; padding: 24px;
border-radius: 16px; border-radius: 16px;
background: rgb(from var(--color-bg-primary) r g b / 60%); 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%); box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
@@ -123,7 +163,6 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 16px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
text-wrap: balance; text-wrap: balance;
@@ -135,8 +174,12 @@
.title { .title {
text-transform: uppercase; text-transform: uppercase;
color: #c2c8ff; color: var(--color-text-brand-soft);
font-weight: 500; font-weight: 500;
&:last-child {
margin-bottom: -3px;
}
} }
.statLarge { .statLarge {
@@ -150,11 +193,15 @@
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
overflow-wrap: break-word; overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
} }
.mostBoostedPost { .mostBoostedPost {
padding: 0; padding: 0;
padding-top: 8px; padding-top: 24px;
overflow: hidden; overflow: hidden;
} }
@@ -166,7 +213,7 @@
'followers hashtag' 'followers hashtag'
'new-posts hashtag'; 'new-posts hashtag';
@media (width < 680px) { @media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-areas: grid-template-areas:
'followers new-posts' 'followers new-posts'
@@ -191,7 +238,7 @@
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag'; grid-template-areas: 'number hashtag';
@media (width < 680px) { @media (width < $mobile-breakpoint) {
grid-template-areas: grid-template-areas:
'number number' 'number number'
'hashtag hashtag'; 'hashtag hashtag';
@@ -229,7 +276,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
p {
max-width: 460px;
}
} }
.archetypeArtboard { .archetypeArtboard {
@@ -281,3 +332,20 @@
left: 0; left: 0;
mix-blend-mode: screen; mix-blend-mode: screen;
} }
.shareButtonWrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.secondaryShareButton {
// Extra selector is needed to override color
&:global(.button) {
color: var(--color-text-primary);
}
}
.navItemBadge {
background: var(--color-bg-brand-soft);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -11,7 +11,11 @@ import { closeModal } from '@/flavours/glitch/actions/modal';
import { IconButton } from '@/flavours/glitch/components/icon_button'; import { IconButton } from '@/flavours/glitch/components/icon_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { me } from '@/flavours/glitch/initial_state'; import { me } from '@/flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Archetype } from './archetype'; import { Archetype } from './archetype';
@@ -23,27 +27,26 @@ import { NewPosts } from './new_posts';
const moduleClassNames = classNames.bind(styles); const moduleClassNames = classNames.bind(styles);
export const shareMessage = defineMessage({ const accountSelector = createAppSelector(
id: 'annual_report.summary.share_message', [(state) => state.accounts, (state) => state.annualReport.report],
defaultMessage: 'I got the {archetype} archetype!', (accounts, report) => {
}); if (me) {
return accounts.get(me);
}
if (report?.schema_version === 2) {
return accounts.get(report.account_id);
}
return undefined;
},
);
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone', context = 'standalone',
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report); const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector((state) => { const account = useAppSelector(accountSelector);
if (me) {
return state.accounts.get(me);
}
if (report?.schema_version === 2) {
return state.accounts.get(report.account_id);
}
return undefined;
});
const close = useCallback(() => { const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
@@ -67,23 +70,16 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
0, 0,
); );
const newFollowerCount = report.data.time_series.reduce( const newFollowerCount =
(sum, item) => sum + item.followers, context === 'modal' &&
0, report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
);
const topHashtag = report.data.top_hashtags[0]; const topHashtag = report.data.top_hashtags[0];
return ( return (
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}> <div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}> <div className={styles.header}>
<h1> <h1>Wrapstodon {report.year}</h1>
<FormattedMessage
id='annual_report.summary.title'
defaultMessage='Wrapstodon {year}'
values={{ year: report.year }}
/>
</h1>
{account && <p>@{account.acct}</p>} {account && <p>@{account.acct}</p>}
{context === 'modal' && ( {context === 'modal' && (
<IconButton <IconButton
@@ -100,7 +96,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
</div> </div>
<div className={styles.stack}> <div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} /> <HighlightedPost data={report.data.top_statuses} context={context} />
<div <div
className={moduleClassNames(styles.statsGrid, { className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag, noHashtag: !topHashtag,
@@ -110,13 +106,15 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
> >
{!!newFollowerCount && <Followers count={newFollowerCount} />} {!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />} {!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && <MostUsedHashtag hashtag={topHashtag} />} {topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div> </div>
<Archetype <Archetype report={report} account={account} context={context} />
report={report}
account={account}
canShare={context === 'modal'}
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,17 @@
import { useEffect } from 'react'; import type { MouseEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { closeModal } from '@/flavours/glitch/actions/modal';
import {
generateReport,
selectWrapstodonYear,
} from '@/flavours/glitch/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AnnualReport } from '.'; import { AnnualReport } from '.';
import { AnnualReportAnnouncement } from './announcement';
import styles from './index.module.scss'; import styles from './index.module.scss';
const AnnualReportModal: React.FC<{ const AnnualReportModal: React.FC<{
@@ -12,15 +21,66 @@ const AnnualReportModal: React.FC<{
onChangeBackgroundColor('var(--color-bg-media-base)'); onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]); }, [onChangeBackgroundColor]);
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const showAnnouncement = year && state && state !== 'available';
const dispatch = useAppDispatch();
const handleBuildRequest = useCallback(() => {
void dispatch(generateReport());
}, [dispatch]);
const handleClose = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [dispatch]);
const handleCloseModal: MouseEventHandler = useCallback(
(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
},
[handleClose],
);
// Auto-close if ineligible
useEffect(() => {
if (state === 'ineligible') {
handleClose();
}
}, [handleClose, state]);
if (state === 'ineligible') {
// Not sure how you got here, but don't show anything.
return null;
}
return ( return (
// It's fine not to provide a keyboard handler here since there is a global
// [Esc] key listener that will close open modals.
// This onClick handler is needed since the modalWrapper styles overlap the
// default modal backdrop, preventing clicks to pass through.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div <div
className={classNames( className={classNames(
'modal-root__modal', 'modal-root__modal',
styles.modalWrapper, styles.modalWrapper,
'theme-dark', 'theme-dark',
)} )}
onClick={handleCloseModal}
> >
<AnnualReport context='modal' /> {!showAnnouncement ? (
<AnnualReport context='modal' />
) : (
<AnnualReportAnnouncement
year={year.toString()}
state={state}
onDismiss={handleClose}
onRequestBuild={handleBuildRequest}
/>
)}
</div> </div>
); );
}; };

View File

@@ -8,7 +8,9 @@ import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{ export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount; hashtag: NameAndCount;
}> = ({ hashtag }) => { name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return ( return (
<div <div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)} className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
@@ -23,11 +25,21 @@ export const MostUsedHashtag: React.FC<{
<div className={styles.statExtraLarge}>#{hashtag.name}</div> <div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p> <p>
<FormattedMessage {context === 'modal' ? (
id='annual_report.summary.most_used_hashtag.used_count' <FormattedMessage
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.' id='annual_report.summary.most_used_hashtag.used_count'
values={{ count: hashtag.count }} defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
/> values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p> </p>
</div> </div>
); );

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { openModal } from '@/flavours/glitch/actions/modal';
import { Icon } from '@/flavours/glitch/components/icon';
import { selectWrapstodonYear } from '@/flavours/glitch/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/store';
import IconPlanet from '@/images/icons/icon_planet.svg?react';
import classes from './index.module.scss';
const selectReportModalOpen = createAppSelector(
[(state) => state.modal.getIn(['stack', 0, 'modalType'])],
(modalType) => modalType === 'ANNUAL_REPORT',
);
export const AnnualReportNavItem: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const active = useAppSelector(selectReportModalOpen);
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
}, [dispatch]);
if (!year || !state || state === 'ineligible') {
return null;
}
return (
<button
type='button'
className={classNames('column-link column-link--transparent', { active })}
onClick={handleClick}
>
<Icon icon={IconPlanet} id='wrapstodon-planet' width='24' height='24' />
<span>Wrapstodon {year}</span>
<span className={classNames('column-link__badge', classes.navItemBadge)}>
<FormattedMessage
id='annual_report.nav_item.badge'
defaultMessage='New'
/>
</span>
</button>
);
};

View File

@@ -1,27 +1,52 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { showAlert } from '@/flavours/glitch/actions/alerts';
import { resetCompose, focusCompose } from '@/flavours/glitch/actions/compose'; import { resetCompose, focusCompose } from '@/flavours/glitch/actions/compose';
import { closeModal } from '@/flavours/glitch/actions/modal'; import { closeModal } from '@/flavours/glitch/actions/modal';
import { Button } from '@/flavours/glitch/components/button'; import { Button } from '@/flavours/glitch/components/button';
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report'; import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
import { useAppDispatch } from '@/flavours/glitch/store'; import { useAppDispatch } from '@/flavours/glitch/store';
import { shareMessage } from '.';
import { archetypeNames } from './archetype'; import { archetypeNames } from './archetype';
import styles from './index.module.scss';
const messages = defineMessages({
share_message: {
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
},
share_on_mastodon: {
id: 'annual_report.summary.share_on_mastodon',
defaultMessage: 'Share on Mastodon',
},
share_elsewhere: {
id: 'annual_report.summary.share_elsewhere',
defaultMessage: 'Share elsewhere',
},
copy_link: {
id: 'annual_report.summary.copy_link',
defaultMessage: 'Copy link',
},
copied: {
id: 'copy_icon_button.copied',
defaultMessage: 'Copied to clipboard',
},
});
export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => { const handleShareClick = useCallback(() => {
// Generate the share message. // Generate the share message.
const archetypeName = intl.formatMessage( const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype], archetypeNames[report.data.archetype],
); );
const shareLines = [ const shareLines = [
intl.formatMessage(shareMessage, { intl.formatMessage(messages.share_message, {
archetype: archetypeName, archetype: archetypeName,
}), }),
]; ];
@@ -37,5 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]); }, [report, intl, dispatch]);
return <Button text='Share here' onClick={handleShareClick} />; const supportsNativeShare = 'share' in navigator;
const handleSecondaryShare = useCallback(() => {
if (report.schema_version === 2 && report.share_url) {
if (supportsNativeShare) {
void navigator.share({
url: report.share_url,
});
} else {
void navigator.clipboard.writeText(report.share_url);
dispatch(showAlert({ message: messages.copied }));
}
}
}, [report, supportsNativeShare, dispatch]);
return (
<div className={styles.shareButtonWrapper}>
<Button
text={intl.formatMessage(messages.share_on_mastodon)}
onClick={handleShareClick}
/>
<Button
plain
className={styles.secondaryShareButton}
text={intl.formatMessage(
supportsNativeShare ? messages.share_elsewhere : messages.copy_link,
)}
onClick={handleSecondaryShare}
/>
</div>
);
}; };

View File

@@ -1,19 +0,0 @@
.wrapper {
max-width: max-content;
margin: 40px auto;
}
.footer {
text-align: center;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}

View File

@@ -0,0 +1,45 @@
$mobile-breakpoint: 540px;
.wrapper {
box-sizing: border-box;
max-width: 600px;
margin-inline: auto;
padding: 40px 10px;
@media (width < $mobile-breakpoint) {
padding-top: 0;
padding-inline: 0;
}
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-top: 2rem;
font-size: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-text-primary);
}
}

View File

@@ -1,9 +1,12 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/flavours/glitch/components/logo'; import { IconLogo } from '@/flavours/glitch/components/logo';
import { me } from '@/flavours/glitch/initial_state';
import { AnnualReport } from './index'; import { AnnualReport } from './index';
import classes from './shared_page.module.css'; import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => { export const WrapstodonSharedPage: FC = () => {
return ( return (
@@ -11,7 +14,30 @@ export const WrapstodonSharedPage: FC = () => {
<AnnualReport /> <AnnualReport />
<footer className={classes.footer}> <footer className={classes.footer}>
<IconLogo className={classes.logo} /> <IconLogo className={classes.logo} />
Generated with by the Mastodon team <FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
/>
<nav className={classes.nav}>
<a href='https://joinmastodon.org'>
<FormattedMessage id='footer.about' defaultMessage='About' />
</a>
{!me && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</nav>
</footer> </footer>
</main> </main>
); );

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { openModal } from '@/flavours/glitch/actions/modal'; import { openModal } from '@/flavours/glitch/actions/modal';
import { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
import { import {
generateReport, generateReport,
selectWrapstodonYear, selectWrapstodonYear,
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
void dispatch(generateReport()); void dispatch(generateReport());
}, [dispatch]); }, [dispatch]);
const { wasDismissed, dismiss } = useDismissible(
`annual_report_announcement_${year}`,
);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} })); dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
}, [dispatch]); dismiss();
}, [dismiss, dispatch]);
if (!year || !state || state === 'ineligible') { if (!year || wasDismissed || !state || state === 'ineligible') {
return null; return null;
} }
return ( return (
<AnnualReportAnnouncement <AnnualReportAnnouncement
year={year.toString()} year={year.toString()}
hasData={state === 'available'} state={state}
isLoading={state === 'generating'}
onRequestBuild={handleBuildRequest} onRequestBuild={handleBuildRequest}
onOpen={handleOpen} onOpen={handleOpen}
onDismiss={dismiss}
/> />
); );
}; };

View File

@@ -41,8 +41,8 @@ const persistVolume = (volume: number, muted: boolean) => {
}; };
const restoreVolume = (audio: HTMLAudioElement) => { const restoreVolume = (audio: HTMLAudioElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5; const volume = playerSettings.get('volume') ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false; const muted = playerSettings.get('muted') ?? false;
audio.volume = volume; audio.volume = volume;
audio.muted = muted; audio.muted = muted;

View File

@@ -1,26 +1,34 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => ( import { criticalUpdatesPending } from '@/flavours/glitch/initial_state';
<div className='warning-banner'>
<div className='warning-banner__message'> export const CriticalUpdateBanner: FC = () => {
<h1> if (!criticalUpdatesPending) {
return null;
}
return (
<div className='warning-banner'>
<div className='warning-banner__message'>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.title' id='home.pending_critical_update.title'
defaultMessage='Critical security update available!' defaultMessage='Critical security update available!'
tagName='h1'
/> />
</h1> <p>
<p>
<FormattedMessage
id='home.pending_critical_update.body'
defaultMessage='Please update your Mastodon server as soon as possible!'
/>{' '}
<a href='/admin/software_updates'>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.link' id='home.pending_critical_update.body'
defaultMessage='See updates' defaultMessage='Please update your Mastodon server as soon as possible!'
/> />{' '}
</a> <a href='/admin/software_updates'>
</p> <FormattedMessage
id='home.pending_critical_update.link'
defaultMessage='See updates'
/>
</a>
</p>
</div>
</div> </div>
</div> );
); };

View File

@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { criticalUpdatesPending } from 'flavours/glitch/initial_state';
import { withBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint'; import { withBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
@@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings'; import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner'; import { CriticalUpdateBanner } from './components/critical_update_banner';
import { Announcements } from './components/announcements'; import { Announcements } from './components/announcements';
import { AnnualReportTimeline } from '../annual_report/timeline';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },
@@ -129,7 +129,10 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props; const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.props.identity; const { signedIn } = this.props.identity;
const banners = []; const banners = [
<CriticalUpdateBanner key='critical-update-banner' />,
<AnnualReportTimeline key='annual-report' />
];
let announcementsButton; let announcementsButton;
@@ -147,10 +150,6 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader

View File

@@ -51,6 +51,8 @@ import { canViewFeed } from 'flavours/glitch/permissions';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { AnnualReportNavItem } from '../annual_report/nav_item';
import { DisabledAccountBanner } from './components/disabled_account_banner'; import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel'; import { FollowedTagsPanel } from './components/followed_tags_panel';
import { ListPanel } from './components/list_panel'; import { ListPanel } from './components/list_panel';
@@ -318,6 +320,8 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
<FollowRequestsLink /> <FollowRequestsLink />
<AnnualReportNavItem />
<hr /> <hr />
<ListPanel /> <ListPanel />

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/flavours/glitch/actions/timelines'; import { scrollTopTimeline, loadPending } from '@/flavours/glitch/actions/timelines';
import { isNonStatusId } from '@/flavours/glitch/actions/timelines_typed';
import StatusList from '@/flavours/glitch/components/status_list'; import StatusList from '@/flavours/glitch/components/status_list';
import { me } from '@/flavours/glitch/initial_state'; import { me } from '@/flavours/glitch/initial_state';
import { TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
const getRegex = createSelector([ const getRegex = createSelector([
(state, { regex }) => regex, (state, { regex }) => regex,
@@ -29,7 +29,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
getRegex, getRegex,
], (columnSettings, statusIds, statuses, regex) => { ], (columnSettings, statusIds, statuses, regex) => {
return statusIds.filter(id => { return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true; if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id); const statusForId = statuses.get(id);

View File

@@ -139,8 +139,8 @@ const persistVolume = (volume: number, muted: boolean) => {
}; };
const restoreVolume = (video: HTMLVideoElement) => { const restoreVolume = (video: HTMLVideoElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5; const volume = playerSettings.get('volume') ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false; const muted = playerSettings.get('muted') ?? false;
video.volume = volume; video.volume = volume;
video.muted = muted; video.muted = muted;

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect } from 'react';
import type { Map as ImmutableMap } from 'immutable';
import { changeSetting } from '@/flavours/glitch/actions/settings';
import { bannerSettings } from '@/flavours/glitch/settings';
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
export function useDismissible(id: string) {
// We use "dismissed_banners" as that was what this was previously called,
// but we can use this to track any dismissible state.
const dismissed = useAppSelector(
(state) =>
!!(
state.settings as ImmutableMap<
'dismissed_banners',
ImmutableMap<string, boolean>
>
).getIn(['dismissed_banners', id], false),
);
const wasDismissed = !!bannerSettings.get(id) || dismissed;
const dispatch = useAppDispatch();
const dismiss = useCallback(() => {
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
// Store legacy localStorage setting on server
if (wasDismissed && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, wasDismissed, dismissed]);
return {
wasDismissed,
dismiss,
};
}

View File

@@ -16,9 +16,9 @@ export interface TimeSeriesMonth {
} }
export interface TopStatuses { export interface TopStatuses {
by_reblogs: number; by_reblogs: string;
by_favourites: number; by_favourites: string;
by_replies: number; by_replies: string;
} }
export type Archetype = export type Archetype =

View File

@@ -5,7 +5,6 @@ import {
importFetchedAccounts, importFetchedAccounts,
importFetchedStatuses, importFetchedStatuses,
} from '@/flavours/glitch/actions/importer'; } from '@/flavours/glitch/actions/importer';
import { insertIntoTimeline } from '@/flavours/glitch/actions/timelines';
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report'; import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
import { import {
apiGetAnnualReport, apiGetAnnualReport,
@@ -20,8 +19,6 @@ import {
createDataLoadingThunk, createDataLoadingThunk,
} from '../../store/typed_functions'; } from '../../store/typed_functions';
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
interface AnnualReportState { interface AnnualReportState {
state?: ApiAnnualReportState; state?: ApiAnnualReportState;
report?: AnnualReport; report?: AnnualReport;
@@ -63,18 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
// This kicks everything off, and is called after fetching the server info. // This kicks everything off, and is called after fetching the server info.
export const checkAnnualReport = createAppThunk( export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
async (_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const year = selectWrapstodonYear(getState());
if (!year) { if (!year) {
return; return;
} }
const state = await dispatch(fetchReportState()); void dispatch(fetchReportState());
if (
state.meta.requestStatus === 'fulfilled' &&
state.payload !== 'ineligible'
) {
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
}
}, },
); );

View File

@@ -1,6 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed'; import { timelineDelete, isNonStatusId } from 'flavours/glitch/actions/timelines_typed';
import { import {
blockAccountSuccess, blockAccountSuccess,
@@ -19,7 +19,6 @@ import {
TIMELINE_MARK_AS_PARTIAL, TIMELINE_MARK_AS_PARTIAL,
TIMELINE_INSERT, TIMELINE_INSERT,
TIMELINE_GAP, TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
disconnectTimeline, disconnectTimeline,
} from '../actions/timelines'; } from '../actions/timelines';
import { compareId } from '../compare_id'; import { compareId } from '../compare_id';
@@ -36,7 +35,6 @@ const initialTimeline = ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
}); });
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because: // This method is pretty tricky because:
@@ -69,20 +67,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
// First, find the furthest (if properly sorted, oldest) item in the timeline that is // First, find the furthest (if properly sorted, oldest) item in the timeline that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap. // newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item. // Start the gap *after* that item.
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1; const lastIndex = oldIds.findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.last()) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
// is newer than the most recent fetched one, as it delimits a section comprised of only // is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newIds` (or that were deleted from the server, so should be removed // items older or within `newIds` (or that were deleted from the server, so should be removed
// anyway). // anyway).
// Stop the gap *after* that item. // Stop the gap *after* that item.
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1; const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.first()) > 0) + 1;
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => { let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
// It is possible, though unlikely, that the slice we are replacing contains items older // It is possible, though unlikely, that the slice we are replacing contains items older
// than the elements we got from the API. Get them and add them back at the back of the // than the elements we got from the API. Get them and add them back at the back of the
// slice. // slice.
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0); const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isNonStatusId(id) && compareId(id, newIds.last()) < 0);
insertedIds.union(olderIds); insertedIds.union(olderIds);
// Make sure we aren't inserting duplicates // Make sure we aren't inserting duplicates

View File

@@ -1,51 +0,0 @@
export default class Settings {
constructor(keyBase = null) {
this.keyBase = keyBase;
}
generateKey(id) {
return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
}
set(id, data) {
const key = this.generateKey(id);
try {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch {
return null;
}
}
get(id) {
const key = this.generateKey(id);
try {
const rawData = localStorage.getItem(key);
return JSON.parse(rawData);
} catch {
return null;
}
}
remove(id) {
const data = this.get(id);
if (data) {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch {
// ignore if the key is not found
}
}
return data;
}
}
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
export const tagHistory = new Settings('mastodon_tag_history');
export const bannerSettings = new Settings('mastodon_banner_settings');
export const searchHistory = new Settings('mastodon_search_history');
export const playerSettings = new Settings('mastodon_player');

View File

@@ -0,0 +1,68 @@
import type { RecentSearch } from './models/search';
export class Settings<T extends Record<string, unknown>> {
keyBase: string | null;
constructor(keyBase: string | null = null) {
this.keyBase = keyBase;
}
private generateKey(id: string | number | symbol): string {
const idStr = typeof id === 'string' ? id : String(id);
return this.keyBase ? [this.keyBase, `id${idStr}`].join('.') : idStr;
}
set<K extends keyof T>(id: K, data: T[K]): T[K] | null {
const key = this.generateKey(id);
try {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch {
return null;
}
}
get<K extends keyof T>(id: K): T[K] | null {
const key = this.generateKey(id);
try {
const rawData = localStorage.getItem(key);
if (rawData === null) return null;
return JSON.parse(rawData) as T[K];
} catch {
return null;
}
}
remove<K extends keyof T>(id: K): T[K] | null {
const data = this.get(id);
if (data !== null) {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch {
// ignore if the key is not found
}
}
return data;
}
}
export const pushNotificationsSetting = new Settings<
Record<string, { alerts: unknown }>
>('mastodon_push_notification_data');
export const tagHistory = new Settings<Record<string, string[]>>(
'mastodon_tag_history',
);
export const bannerSettings = new Settings<Record<string, boolean>>(
'mastodon_banner_settings',
);
export const searchHistory = new Settings<Record<string, RecentSearch[]>>(
'mastodon_search_history',
);
export const playerSettings = new Settings<{ volume: number; muted: boolean }>(
'mastodon_player',
);
export const wrapstodonSettings = new Settings<
Record<string, { archetypeRevealed: boolean }>
>('wrapstodon');

View File

@@ -6,7 +6,7 @@
html { html {
@include base.palette; @include base.palette;
&[data-user-theme='system'] { &:where([data-user-theme='system']) {
color-scheme: dark light; color-scheme: dark light;
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View File

@@ -14,3 +14,9 @@
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>; Partial<Pick<T, K>>;
export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
export type AnyFunction = (...args: never) => unknown;

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" d="M9.2 8.525c.35 0 .646-.12.888-.363.241-.241.362-.537.362-.887s-.12-.646-.362-.888a1.207 1.207 0 0 0-.888-.362c-.35 0-.646.12-.887.362a1.207 1.207 0 0 0-.363.888c0 .35.12.646.362.887.242.242.538.363.888.363ZM18.525 20c-.7 0-1.642-.292-2.825-.875-1.183-.583-2.45-1.375-3.8-2.375a7.564 7.564 0 0 1-1.95.25C8 17 6.35 16.325 5 14.975c-1.35-1.35-2.025-3-2.025-4.95 0-.333.025-.667.075-1a9.18 9.18 0 0 1 .2-.975C2.267 6.7 1.48 5.437.888 4.262.296 3.089 0 2.15 0 1.45 0 1 .125.646.375.387.625.13.967 0 1.4 0c.433 0 .996.15 1.688.45.691.3 1.645.808 2.862 1.525-.35.183-.675.375-.975.575-.3.2-.592.417-.875.65a9.777 9.777 0 0 0-.925-.475c-.3-.133-.617-.275-.95-.425.3.633.62 1.25.962 1.85.342.6.705 1.192 1.088 1.775A6.775 6.775 0 0 1 6.7 3.8c.983-.517 2.067-.775 3.25-.775 1.95 0 3.604.68 4.963 2.038 1.358 1.358 2.037 3.012 2.037 4.962 0 1.183-.262 2.267-.787 3.25a6.89 6.89 0 0 1-2.138 2.425c.583.383 1.18.75 1.787 1.1.609.35 1.23.667 1.863.95-.133-.317-.27-.625-.413-.925-.141-.3-.304-.608-.487-.925.25-.283.475-.583.675-.9.2-.317.383-.642.55-.975.767 1.3 1.288 2.27 1.563 2.912.274.642.412 1.18.412 1.613 0 .483-.133.846-.4 1.087-.267.242-.617.363-1.05.363ZM11.7 13.025c.283 0 .52-.096.713-.287a.968.968 0 0 0 .287-.713.968.968 0 0 0-.287-.713.968.968 0 0 0-.713-.287.968.968 0 0 0-.712.287.968.968 0 0 0-.288.713c0 .283.096.52.288.713.191.191.429.287.712.287Zm1.25-3.5a.728.728 0 0 0 .75-.75.728.728 0 0 0-.75-.75.728.728 0 0 0-.75.75.728.728 0 0 0 .75.75Zm-3.275 5.45a45.451 45.451 0 0 1-2.45-2.275 39.166 39.166 0 0 1-2.25-2.45 4.944 4.944 0 0 0 1.45 3.275c.433.433.925.775 1.475 1.025.55.25 1.142.392 1.775.425Zm2.575-.525c.8-.417 1.45-1.02 1.95-1.813.5-.791.75-1.67.75-2.637 0-1.383-.487-2.558-1.462-3.525-.976-.967-2.155-1.45-3.538-1.45-.967 0-1.842.25-2.625.75a5.052 5.052 0 0 0-1.8 1.95 33.58 33.58 0 0 0 3.125 3.6 33.583 33.583 0 0 0 3.6 3.125Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -144,7 +144,7 @@ export const hydrateSearch = createAppAsyncThunk(
'search/hydrate', 'search/hydrate',
(_args, { dispatch, getState }) => { (_args, { dispatch, getState }) => {
const me = getState().meta.get('me') as string; const me = getState().meta.get('me') as string;
const history = searchHistory.get(me) as RecentSearch[] | null; const history = searchHistory.get(me);
if (history !== null) { if (history !== null) {
dispatch(updateSearchHistory(history)); dispatch(updateSearchHistory(history));

View File

@@ -6,7 +6,7 @@ import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed'; import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed'; export { disconnectTimeline } from './timelines_typed';
@@ -24,9 +24,15 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT'; 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_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null; export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
];
export const loadPending = timeline => ({ export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING, type: TIMELINE_LOAD_PENDING,
timeline, timeline,

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; 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( export const disconnectTimeline = createAction(
'timeline/disconnect', 'timeline/disconnect',
({ timeline }: { timeline: string }) => ({ ({ timeline }: { timeline: string }) => ({

View File

@@ -1,12 +1,10 @@
import type { PropsWithChildren } from 'react'; import type { FC, ReactNode } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import { bannerSettings } from 'mastodon/settings'; import { useDismissible } from '../hooks/useDismissible';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@@ -16,48 +14,12 @@ const messages = defineMessages({
interface Props { interface Props {
id: string; id: string;
children: ReactNode;
} }
export function useDismissableBannerState({ id }: Props) { export const DismissableBanner: FC<Props> = ({ id, children }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dismissed: boolean = useAppSelector((state) =>
/* eslint-disable-next-line */
state.settings.getIn(['dismissed_banners', id], false),
);
const [isVisible, setIsVisible] = useState(
!bannerSettings.get(id) && !dismissed,
);
const dispatch = useAppDispatch();
const dismiss = useCallback(() => {
setIsVisible(false);
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
// Store legacy localStorage setting on server
if (!isVisible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, isVisible, dismissed]);
return {
wasDismissed: !isVisible,
dismiss,
};
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(id);
id,
});
if (wasDismissed) { if (wasDismissed) {
return null; return null;

View File

@@ -0,0 +1,45 @@
import { useCallback, useRef } from 'react';
export const InterceptStatusClicks: React.FC<{
onPreventedClick: (
clickedArea: 'account' | 'post',
event: React.MouseEvent,
) => void;
children: React.ReactNode;
}> = ({ onPreventedClick, children }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const clickTarget = e.target as Element;
const allowedElementsSelector =
'.video-player, .audio-player, .media-gallery, .content-warning';
const allowedElements = wrapperRef.current?.querySelectorAll(
allowedElementsSelector,
);
const isTargetClickAllowed =
allowedElements &&
Array.from(allowedElements).some((element) => {
return clickTarget === element || element.contains(clickTarget);
});
if (!isTargetClickAllowed) {
e.preventDefault();
e.stopPropagation();
const wasAccountAreaClicked = !!clickTarget.closest(
'a.status__display-name',
);
onPreventedClick(wasAccountAreaClicked ? 'account' : 'post', e);
}
},
[onPreventedClick],
);
return (
<div ref={wrapperRef} onClickCapture={handleClick}>
{children}
</div>
);
};

View File

@@ -6,13 +6,13 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button'; import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon'; import { Icon } from '../icon';
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; const DISMISSIBLE_BANNER_ID = 'notifications/remove_quote_hint';
/** /**
* We don't want to show this hint in the UI more than once, * We don't want to show this hint in the UI more than once,
@@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{
const anchorRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl(); const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({ const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
id: DISMISSABLE_BANNER_ID,
});
const shouldShowHint = !wasDismissed && canShowHint; const shouldShowHint = !wasDismissed && canShowHint;

View File

@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import { AnnualReportTimeline } from 'mastodon/features/annual_report/timeline';
import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
import { StatusQuoteManager } from '../components/status_quoted'; import { StatusQuoteManager } from '../components/status_quoted';
@@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent {
return ( return (
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} /> <InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
); );
case TIMELINE_WRAPSTODON:
return (
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
)
case TIMELINE_GAP: case TIMELINE_GAP:
return ( return (
<LoadGap <LoadGap

View File

@@ -1,38 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test'; import { action } from 'storybook/actions';
import type { AnyFunction, OmitValueType } from '@/mastodon/utils/types';
import type { AnnualReportAnnouncementProps } from '.';
import { AnnualReportAnnouncement } from '.'; import { AnnualReportAnnouncement } from '.';
const meta = { type Props = OmitValueType<
title: 'Components/AnnualReportAnnouncement', // We can't use the name 'state' here because it's reserved for overriding Redux state.
component: AnnualReportAnnouncement, Omit<AnnualReportAnnouncementProps, 'state'> & {
args: { reportState: AnnualReportAnnouncementProps['state'];
hasData: false,
isLoading: false,
year: '2025',
onRequestBuild: fn(),
onOpen: fn(),
}, },
} satisfies Meta<typeof AnnualReportAnnouncement>; AnyFunction // Remove any functions, as they can't meaningfully be controlled in Storybook.
>;
const meta = {
title: 'Components/AnnualReport/Announcement',
args: {
reportState: 'eligible',
year: '2025',
},
argTypes: {
reportState: {
control: {
type: 'select',
},
options: ['eligible', 'generating', 'available'],
},
},
render({ reportState, ...args }: Props) {
return (
<AnnualReportAnnouncement
state={reportState}
{...args}
onDismiss={action('dismissed announcement')}
onOpen={action('opened report modal')}
onRequestBuild={action('requested build')}
/>
);
},
} satisfies Meta<Props>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {};
render: (args) => <AnnualReportAnnouncement {...args} />,
};
export const Loading: Story = { export const Loading: Story = {
args: { args: {
isLoading: true, reportState: 'generating',
}, },
render: Default.render,
}; };
export const WithData: Story = { export const WithData: Story = {
args: { args: {
hasData: true, reportState: 'available',
}, },
render: Default.render,
}; };

View File

@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
export const AnnualReportAnnouncement: React.FC<{ export interface AnnualReportAnnouncementProps {
year: string; year: string;
hasData: boolean; state: Exclude<ApiAnnualReportState, 'ineligible'>;
isLoading: boolean;
onRequestBuild: () => void; onRequestBuild: () => void;
onOpen: () => void; onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then.
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { onDismiss: () => void;
}
export const AnnualReportAnnouncement: React.FC<
AnnualReportAnnouncementProps
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
return ( return (
<div className={classNames('theme-dark', styles.wrapper)}> <div className={classNames('theme-dark', styles.wrapper)}>
<h2> <FormattedMessage
<FormattedMessage id='annual_report.announcement.title'
id='annual_report.announcement.title' defaultMessage='Wrapstodon {year} has arrived'
defaultMessage='Wrapstodon {year} has arrived' values={{ year }}
values={{ year }} tagName='h2'
/> />
</h2> <FormattedMessage
<p> id='annual_report.announcement.description'
<FormattedMessage defaultMessage='Discover more about your engagement on Mastodon over the past year.'
id='annual_report.announcement.description' tagName='p'
defaultMessage='Discover more about your engagement on Mastodon over the past year.' />
/> {state === 'available' ? (
</p>
{hasData ? (
<Button onClick={onOpen}> <Button onClick={onOpen}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_view' id='annual_report.announcement.action_view'
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
/> />
</Button> </Button>
) : ( ) : (
<Button loading={isLoading} onClick={onRequestBuild}> <Button loading={state === 'generating'} onClick={onRequestBuild}>
<FormattedMessage <FormattedMessage
id='annual_report.announcement.action_build' id='annual_report.announcement.action_build'
defaultMessage='Build my Wrapstodon' defaultMessage='Build my Wrapstodon'
/> />
</Button> </Button>
)} )}
{state === 'eligible' && (
<Button onClick={onDismiss} plain className={styles.closeButton}>
<FormattedMessage
id='annual_report.announcement.action_dismiss'
defaultMessage='No thanks'
/>
</Button>
)}
</div> </div>
); );
}; };

View File

@@ -15,6 +15,8 @@
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%) radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
var(--color-bg-primary); var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
position: relative;
pointer-events: all;
h2 { h2 {
font-size: 20px; font-size: 20px;
@@ -26,4 +28,15 @@
p { p {
margin-bottom: 20px; margin-bottom: 20px;
} }
.closeButton {
position: absolute;
bottom: 8px;
right: 8px;
margin-inline: 0;
}
:global(.modal-root__modal) & {
border-radius: 16px;
}
} }

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
}),
},
},
};

View File

@@ -18,6 +18,7 @@ import type {
AnnualReport, AnnualReport,
Archetype as ArchetypeData, Archetype as ArchetypeData,
} from '@/mastodon/models/annual_report'; } from '@/mastodon/models/annual_report';
import { wrapstodonSettings } from '@/mastodon/settings';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { ShareButton } from './share_button'; import { ShareButton } from './share_button';
@@ -112,15 +113,22 @@ const illustrations = {
export const Archetype: React.FC<{ export const Archetype: React.FC<{
report: AnnualReport; report: AnnualReport;
account?: Account; account?: Account;
canShare: boolean; context: 'modal' | 'standalone';
}> = ({ report, account, canShare }) => { }> = ({ report, account, context }) => {
const intl = useIntl(); const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = account?.id === me; const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView); const [isRevealed, setIsRevealed] = useState(
() =>
!isSelfView ||
(me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true),
);
const reveal = useCallback(() => { const reveal = useCallback(() => {
setIsRevealed(true); setIsRevealed(true);
if (me) {
wrapstodonSettings.set(me, { archetypeRevealed: true });
}
wrapperRef.current?.focus(); wrapperRef.current?.focus();
}, []); }, []);
@@ -129,7 +137,8 @@ export const Archetype: React.FC<{
? archetypeSelfDescriptions ? archetypeSelfDescriptions
: archetypePublicDescriptions; : archetypePublicDescriptions;
const name = account?.display_name; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty
const name = account?.display_name || account?.username;
return ( return (
<div <div
@@ -209,7 +218,7 @@ export const Archetype: React.FC<{
/> />
</Button> </Button>
)} )}
{isRevealed && canShare && <ShareButton report={report} />} {isRevealed && isSelfView && <ShareButton report={report} />}
</div> </div>
); );
}; };

View File

@@ -4,10 +4,14 @@
@typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call */ @typescript-eslint/no-unsafe-call */
import type { ComponentPropsWithoutRef } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { InterceptStatusClicks } from 'mastodon/components/status/intercept_status_clicks';
import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import type { TopStatuses } from 'mastodon/models/annual_report'; import type { TopStatuses } from 'mastodon/models/annual_report';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
@@ -19,7 +23,8 @@ const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
export const HighlightedPost: React.FC<{ export const HighlightedPost: React.FC<{
data: TopStatuses; data: TopStatuses;
}> = ({ data }) => { context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data; const { by_reblogs, by_favourites, by_replies } = data;
const statusId = by_reblogs || by_favourites || by_replies; const statusId = by_reblogs || by_favourites || by_replies;
@@ -28,6 +33,24 @@ export const HighlightedPost: React.FC<{
statusId ? getStatus(state, { id: statusId }) : undefined, statusId ? getStatus(state, { id: statusId }) : undefined,
); );
const handleClick = useCallback<
ComponentPropsWithoutRef<typeof InterceptStatusClicks>['onPreventedClick']
>(
(clickedArea) => {
const link: string =
clickedArea === 'account'
? status.getIn(['account', 'url'])
: status.get('url');
if (context === 'standalone') {
window.location.href = link;
} else {
window.open(link, '_blank');
}
},
[status, context],
);
if (!status) { if (!status) {
return <div className={classNames(styles.box, styles.mostBoostedPost)} />; return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
} }
@@ -68,10 +91,12 @@ export const HighlightedPost: React.FC<{
defaultMessage='Most popular post' defaultMessage='Most popular post'
/> />
</h2> </h2>
<p>{label}</p> {context === 'modal' && <p>{label}</p>}
</div> </div>
<StatusQuoteManager showActions={false} id={`${statusId}`} /> <InterceptStatusClicks onPreventedClick={handleClick}>
<StatusQuoteManager showActions={false} id={statusId} />
</InterceptStatusClicks>
</div> </div>
); );
}; };

View File

@@ -1,62 +1,99 @@
$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 { .modalWrapper {
position: absolute; box-sizing: border-box;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%;
padding: 40px; padding: 40px;
overflow-y: auto; overflow-y: auto;
pointer-events: none;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary); scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding: 0;
}
.loading-indicator .circular-progress { .loading-indicator .circular-progress {
color: var(--lime); color: var(--lime);
} }
} }
.closeButton { .closeButton {
position: absolute;
top: 24px;
right: 24px;
padding: 8px;
border-radius: 100%;
--default-icon-color: var(--color-bg-primary); --default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary); --default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary); --hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-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 { .wrapper {
--gradient-strength: 0.4;
box-sizing: border-box;
position: relative; position: relative;
max-width: 600px; max-width: 600px;
padding: 24px; padding: 24px;
padding-top: 40px;
contain: layout; contain: layout;
flex: 0 0 auto; flex: 0 0 auto;
pointer-events: auto; pointer-events: all;
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-primary); background: var(--color-bg-primary);
background: background:
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%), radial-gradient(
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%), at 10% 27%,
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%), rgba(83, 12, 154, var(--gradient-strength)) 0,
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%), transparent 50%
radial-gradient(at 80% 91%, #16dae499 0, transparent 50%) ),
radial-gradient(
at 91% 10%,
rgba(30, 24, 223, var(--gradient-strength)) 0,
transparent 25%
),
radial-gradient(
at 10% 91%,
rgba(22, 218, 228, var(--gradient-strength)) 0,
transparent 40%
),
radial-gradient(
at 75% 87%,
rgba(37, 31, 217, var(--gradient-strength)) 0,
transparent 20%
),
radial-gradient(
at 84% 60%,
rgba(95, 30, 148, var(--gradient-strength)) 0,
transparent 40%
)
var(--color-bg-primary); var(--color-bg-primary);
border-radius: 40px; border-radius: 40px;
@media (width < 600px) { @media (width < $mobile-breakpoint) {
padding: 12px; padding-inline: 12px;
} padding-bottom: 12px;
border-radius: 0;
&::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: inherit;
border-radius: inherit;
filter: blur(20px);
} }
} }
@@ -65,13 +102,16 @@
text-align: center; text-align: center;
h1 { h1 {
font-family: monospace; font-family: silkscreen-wrapstodon, monospace;
text-transform: uppercase; font-size: 28px;
letter-spacing: 0.15em; line-height: 1;
font-size: 30px; margin-bottom: 4px;
font-weight: 600; padding-inline: 40px; // Prevent overlap with close button
line-height: 1.5;
margin-bottom: 8px; @media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
} }
p { p {
@@ -89,7 +129,7 @@
.box { .box {
position: relative; position: relative;
padding: 16px; padding: 24px;
border-radius: 16px; border-radius: 16px;
background: rgb(from var(--color-bg-primary) r g b / 60%); 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%); box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
@@ -123,7 +163,6 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 16px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
text-wrap: balance; text-wrap: balance;
@@ -135,8 +174,12 @@
.title { .title {
text-transform: uppercase; text-transform: uppercase;
color: #c2c8ff; color: var(--color-text-brand-soft);
font-weight: 500; font-weight: 500;
&:last-child {
margin-bottom: -3px;
}
} }
.statLarge { .statLarge {
@@ -150,11 +193,15 @@
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
overflow-wrap: break-word; overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
} }
.mostBoostedPost { .mostBoostedPost {
padding: 0; padding: 0;
padding-top: 8px; padding-top: 24px;
overflow: hidden; overflow: hidden;
} }
@@ -166,7 +213,7 @@
'followers hashtag' 'followers hashtag'
'new-posts hashtag'; 'new-posts hashtag';
@media (width < 680px) { @media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-areas: grid-template-areas:
'followers new-posts' 'followers new-posts'
@@ -191,7 +238,7 @@
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag'; grid-template-areas: 'number hashtag';
@media (width < 680px) { @media (width < $mobile-breakpoint) {
grid-template-areas: grid-template-areas:
'number number' 'number number'
'hashtag hashtag'; 'hashtag hashtag';
@@ -229,7 +276,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
p {
max-width: 460px;
}
} }
.archetypeArtboard { .archetypeArtboard {
@@ -281,3 +332,20 @@
left: 0; left: 0;
mix-blend-mode: screen; mix-blend-mode: screen;
} }
.shareButtonWrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.secondaryShareButton {
// Extra selector is needed to override color
&:global(.button) {
color: var(--color-text-primary);
}
}
.navItemBadge {
background: var(--color-bg-brand-soft);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -11,7 +11,11 @@ import { closeModal } from '@/mastodon/actions/modal';
import { IconButton } from '@/mastodon/components/icon_button'; import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { me } from '@/mastodon/initial_state'; import { me } from '@/mastodon/initial_state';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Archetype } from './archetype'; import { Archetype } from './archetype';
@@ -23,27 +27,26 @@ import { NewPosts } from './new_posts';
const moduleClassNames = classNames.bind(styles); const moduleClassNames = classNames.bind(styles);
export const shareMessage = defineMessage({ const accountSelector = createAppSelector(
id: 'annual_report.summary.share_message', [(state) => state.accounts, (state) => state.annualReport.report],
defaultMessage: 'I got the {archetype} archetype!', (accounts, report) => {
}); if (me) {
return accounts.get(me);
}
if (report?.schema_version === 2) {
return accounts.get(report.account_id);
}
return undefined;
},
);
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone', context = 'standalone',
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report); const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector((state) => { const account = useAppSelector(accountSelector);
if (me) {
return state.accounts.get(me);
}
if (report?.schema_version === 2) {
return state.accounts.get(report.account_id);
}
return undefined;
});
const close = useCallback(() => { const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
@@ -67,23 +70,16 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
0, 0,
); );
const newFollowerCount = report.data.time_series.reduce( const newFollowerCount =
(sum, item) => sum + item.followers, context === 'modal' &&
0, report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
);
const topHashtag = report.data.top_hashtags[0]; const topHashtag = report.data.top_hashtags[0];
return ( return (
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}> <div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}> <div className={styles.header}>
<h1> <h1>Wrapstodon {report.year}</h1>
<FormattedMessage
id='annual_report.summary.title'
defaultMessage='Wrapstodon {year}'
values={{ year: report.year }}
/>
</h1>
{account && <p>@{account.acct}</p>} {account && <p>@{account.acct}</p>}
{context === 'modal' && ( {context === 'modal' && (
<IconButton <IconButton
@@ -100,7 +96,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
</div> </div>
<div className={styles.stack}> <div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} /> <HighlightedPost data={report.data.top_statuses} context={context} />
<div <div
className={moduleClassNames(styles.statsGrid, { className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag, noHashtag: !topHashtag,
@@ -110,13 +106,15 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
> >
{!!newFollowerCount && <Followers count={newFollowerCount} />} {!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />} {!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && <MostUsedHashtag hashtag={topHashtag} />} {topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div> </div>
<Archetype <Archetype report={report} account={account} context={context} />
report={report}
account={account}
canShare={context === 'modal'}
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,17 @@
import { useEffect } from 'react'; import type { MouseEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { closeModal } from '@/mastodon/actions/modal';
import {
generateReport,
selectWrapstodonYear,
} from '@/mastodon/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AnnualReport } from '.'; import { AnnualReport } from '.';
import { AnnualReportAnnouncement } from './announcement';
import styles from './index.module.scss'; import styles from './index.module.scss';
const AnnualReportModal: React.FC<{ const AnnualReportModal: React.FC<{
@@ -12,15 +21,66 @@ const AnnualReportModal: React.FC<{
onChangeBackgroundColor('var(--color-bg-media-base)'); onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]); }, [onChangeBackgroundColor]);
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const showAnnouncement = year && state && state !== 'available';
const dispatch = useAppDispatch();
const handleBuildRequest = useCallback(() => {
void dispatch(generateReport());
}, [dispatch]);
const handleClose = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [dispatch]);
const handleCloseModal: MouseEventHandler = useCallback(
(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
},
[handleClose],
);
// Auto-close if ineligible
useEffect(() => {
if (state === 'ineligible') {
handleClose();
}
}, [handleClose, state]);
if (state === 'ineligible') {
// Not sure how you got here, but don't show anything.
return null;
}
return ( return (
// It's fine not to provide a keyboard handler here since there is a global
// [Esc] key listener that will close open modals.
// This onClick handler is needed since the modalWrapper styles overlap the
// default modal backdrop, preventing clicks to pass through.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div <div
className={classNames( className={classNames(
'modal-root__modal', 'modal-root__modal',
styles.modalWrapper, styles.modalWrapper,
'theme-dark', 'theme-dark',
)} )}
onClick={handleCloseModal}
> >
<AnnualReport context='modal' /> {!showAnnouncement ? (
<AnnualReport context='modal' />
) : (
<AnnualReportAnnouncement
year={year.toString()}
state={state}
onDismiss={handleClose}
onRequestBuild={handleBuildRequest}
/>
)}
</div> </div>
); );
}; };

View File

@@ -8,7 +8,9 @@ import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{ export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount; hashtag: NameAndCount;
}> = ({ hashtag }) => { name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return ( return (
<div <div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)} className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
@@ -23,11 +25,21 @@ export const MostUsedHashtag: React.FC<{
<div className={styles.statExtraLarge}>#{hashtag.name}</div> <div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p> <p>
<FormattedMessage {context === 'modal' ? (
id='annual_report.summary.most_used_hashtag.used_count' <FormattedMessage
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.' id='annual_report.summary.most_used_hashtag.used_count'
values={{ count: hashtag.count }} defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
/> values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p> </p>
</div> </div>
); );

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import IconPlanet from '@/images/icons/icon_planet.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { Icon } from '@/mastodon/components/icon';
import { selectWrapstodonYear } from '@/mastodon/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import classes from './index.module.scss';
const selectReportModalOpen = createAppSelector(
[(state) => state.modal.getIn(['stack', 0, 'modalType'])],
(modalType) => modalType === 'ANNUAL_REPORT',
);
export const AnnualReportNavItem: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const active = useAppSelector(selectReportModalOpen);
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
}, [dispatch]);
if (!year || !state || state === 'ineligible') {
return null;
}
return (
<button
type='button'
className={classNames('column-link column-link--transparent', { active })}
onClick={handleClick}
>
<Icon icon={IconPlanet} id='wrapstodon-planet' width='24' height='24' />
<span>Wrapstodon {year}</span>
<span className={classNames('column-link__badge', classes.navItemBadge)}>
<FormattedMessage
id='annual_report.nav_item.badge'
defaultMessage='New'
/>
</span>
</button>
);
};

View File

@@ -1,27 +1,52 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { showAlert } from '@/mastodon/actions/alerts';
import { resetCompose, focusCompose } from '@/mastodon/actions/compose'; import { resetCompose, focusCompose } from '@/mastodon/actions/compose';
import { closeModal } from '@/mastodon/actions/modal'; import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report'; import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
import { useAppDispatch } from '@/mastodon/store'; import { useAppDispatch } from '@/mastodon/store';
import { shareMessage } from '.';
import { archetypeNames } from './archetype'; import { archetypeNames } from './archetype';
import styles from './index.module.scss';
const messages = defineMessages({
share_message: {
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
},
share_on_mastodon: {
id: 'annual_report.summary.share_on_mastodon',
defaultMessage: 'Share on Mastodon',
},
share_elsewhere: {
id: 'annual_report.summary.share_elsewhere',
defaultMessage: 'Share elsewhere',
},
copy_link: {
id: 'annual_report.summary.copy_link',
defaultMessage: 'Copy link',
},
copied: {
id: 'copy_icon_button.copied',
defaultMessage: 'Copied to clipboard',
},
});
export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => { const handleShareClick = useCallback(() => {
// Generate the share message. // Generate the share message.
const archetypeName = intl.formatMessage( const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype], archetypeNames[report.data.archetype],
); );
const shareLines = [ const shareLines = [
intl.formatMessage(shareMessage, { intl.formatMessage(messages.share_message, {
archetype: archetypeName, archetype: archetypeName,
}), }),
]; ];
@@ -37,5 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]); }, [report, intl, dispatch]);
return <Button text='Share here' onClick={handleShareClick} />; const supportsNativeShare = 'share' in navigator;
const handleSecondaryShare = useCallback(() => {
if (report.schema_version === 2 && report.share_url) {
if (supportsNativeShare) {
void navigator.share({
url: report.share_url,
});
} else {
void navigator.clipboard.writeText(report.share_url);
dispatch(showAlert({ message: messages.copied }));
}
}
}, [report, supportsNativeShare, dispatch]);
return (
<div className={styles.shareButtonWrapper}>
<Button
text={intl.formatMessage(messages.share_on_mastodon)}
onClick={handleShareClick}
/>
<Button
plain
className={styles.secondaryShareButton}
text={intl.formatMessage(
supportsNativeShare ? messages.share_elsewhere : messages.copy_link,
)}
onClick={handleSecondaryShare}
/>
</div>
);
}; };

View File

@@ -1,19 +0,0 @@
.wrapper {
max-width: max-content;
margin: 40px auto;
}
.footer {
text-align: center;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}

View File

@@ -0,0 +1,45 @@
$mobile-breakpoint: 540px;
.wrapper {
box-sizing: border-box;
max-width: 600px;
margin-inline: auto;
padding: 40px 10px;
@media (width < $mobile-breakpoint) {
padding-top: 0;
padding-inline: 0;
}
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-top: 2rem;
font-size: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-text-primary);
}
}

View File

@@ -1,9 +1,12 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/mastodon/components/logo'; import { IconLogo } from '@/mastodon/components/logo';
import { me } from '@/mastodon/initial_state';
import { AnnualReport } from './index'; import { AnnualReport } from './index';
import classes from './shared_page.module.css'; import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => { export const WrapstodonSharedPage: FC = () => {
return ( return (
@@ -11,7 +14,30 @@ export const WrapstodonSharedPage: FC = () => {
<AnnualReport /> <AnnualReport />
<footer className={classes.footer}> <footer className={classes.footer}>
<IconLogo className={classes.logo} /> <IconLogo className={classes.logo} />
Generated with by the Mastodon team <FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
/>
<nav className={classes.nav}>
<a href='https://joinmastodon.org'>
<FormattedMessage id='footer.about' defaultMessage='About' />
</a>
{!me && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</nav>
</footer> </footer>
</main> </main>
); );

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import { import {
generateReport, generateReport,
selectWrapstodonYear, selectWrapstodonYear,
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
void dispatch(generateReport()); void dispatch(generateReport());
}, [dispatch]); }, [dispatch]);
const { wasDismissed, dismiss } = useDismissible(
`annual_report_announcement_${year}`,
);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} })); dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
}, [dispatch]); dismiss();
}, [dismiss, dispatch]);
if (!year || !state || state === 'ineligible') { if (!year || wasDismissed || !state || state === 'ineligible') {
return null; return null;
} }
return ( return (
<AnnualReportAnnouncement <AnnualReportAnnouncement
year={year.toString()} year={year.toString()}
hasData={state === 'available'} state={state}
isLoading={state === 'generating'}
onRequestBuild={handleBuildRequest} onRequestBuild={handleBuildRequest}
onOpen={handleOpen} onOpen={handleOpen}
onDismiss={dismiss}
/> />
); );
}; };

View File

@@ -41,8 +41,8 @@ const persistVolume = (volume: number, muted: boolean) => {
}; };
const restoreVolume = (audio: HTMLAudioElement) => { const restoreVolume = (audio: HTMLAudioElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5; const volume = playerSettings.get('volume') ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false; const muted = playerSettings.get('muted') ?? false;
audio.volume = volume; audio.volume = volume;
audio.muted = muted; audio.muted = muted;

View File

@@ -1,26 +1,34 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => ( import { criticalUpdatesPending } from '@/mastodon/initial_state';
<div className='warning-banner'>
<div className='warning-banner__message'> export const CriticalUpdateBanner: FC = () => {
<h1> if (!criticalUpdatesPending) {
return null;
}
return (
<div className='warning-banner'>
<div className='warning-banner__message'>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.title' id='home.pending_critical_update.title'
defaultMessage='Critical security update available!' defaultMessage='Critical security update available!'
tagName='h1'
/> />
</h1> <p>
<p>
<FormattedMessage
id='home.pending_critical_update.body'
defaultMessage='Please update your Mastodon server as soon as possible!'
/>{' '}
<a href='/admin/software_updates'>
<FormattedMessage <FormattedMessage
id='home.pending_critical_update.link' id='home.pending_critical_update.body'
defaultMessage='See updates' defaultMessage='Please update your Mastodon server as soon as possible!'
/> />{' '}
</a> <a href='/admin/software_updates'>
</p> <FormattedMessage
id='home.pending_critical_update.link'
defaultMessage='See updates'
/>
</a>
</p>
</div>
</div> </div>
</div> );
); };

View File

@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { criticalUpdatesPending } from 'mastodon/initial_state';
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
@@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings'; import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner'; import { CriticalUpdateBanner } from './components/critical_update_banner';
import { Announcements } from './components/announcements'; import { Announcements } from './components/announcements';
import { AnnualReportTimeline } from '../annual_report/timeline';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },
@@ -127,7 +127,10 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props; const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.props.identity; const { signedIn } = this.props.identity;
const banners = []; const banners = [
<CriticalUpdateBanner key='critical-update-banner' />,
<AnnualReportTimeline key='annual-report' />
];
let announcementsButton; let announcementsButton;
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader

View File

@@ -46,6 +46,8 @@ import { canViewFeed } from 'mastodon/permissions';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AnnualReportNavItem } from '../annual_report/nav_item';
import { DisabledAccountBanner } from './components/disabled_account_banner'; import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel'; import { FollowedTagsPanel } from './components/followed_tags_panel';
import { ListPanel } from './components/list_panel'; import { ListPanel } from './components/list_panel';
@@ -294,6 +296,8 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
<FollowRequestsLink /> <FollowRequestsLink />
<AnnualReportNavItem />
<hr /> <hr />
<ListPanel /> <ListPanel />

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/mastodon/actions/timelines'; import { scrollTopTimeline, loadPending } from '@/mastodon/actions/timelines';
import { isNonStatusId } from '@/mastodon/actions/timelines_typed';
import StatusList from '@/mastodon/components/status_list'; import StatusList from '@/mastodon/components/status_list';
import { me } from '@/mastodon/initial_state'; import { me } from '@/mastodon/initial_state';
import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
const makeGetStatusIds = (pending = false) => createSelector([ const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()), (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
@@ -15,7 +15,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
(state) => state.get('statuses'), (state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => { ], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => { return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true; if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id); const statusForId = statuses.get(id);

View File

@@ -139,8 +139,8 @@ const persistVolume = (volume: number, muted: boolean) => {
}; };
const restoreVolume = (video: HTMLVideoElement) => { const restoreVolume = (video: HTMLVideoElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5; const volume = playerSettings.get('volume') ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false; const muted = playerSettings.get('muted') ?? false;
video.volume = volume; video.volume = volume;
video.muted = muted; video.muted = muted;

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect } from 'react';
import type { Map as ImmutableMap } from 'immutable';
import { changeSetting } from '@/mastodon/actions/settings';
import { bannerSettings } from '@/mastodon/settings';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
export function useDismissible(id: string) {
// We use "dismissed_banners" as that was what this was previously called,
// but we can use this to track any dismissible state.
const dismissed = useAppSelector(
(state) =>
!!(
state.settings as ImmutableMap<
'dismissed_banners',
ImmutableMap<string, boolean>
>
).getIn(['dismissed_banners', id], false),
);
const wasDismissed = !!bannerSettings.get(id) || dismissed;
const dispatch = useAppDispatch();
const dismiss = useCallback(() => {
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
// Store legacy localStorage setting on server
if (wasDismissed && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, wasDismissed, dismissed]);
return {
wasDismissed,
dismiss,
};
}

View File

@@ -114,9 +114,14 @@
"alt_text_modal.done": "Done", "alt_text_modal.done": "Done",
"announcement.announcement": "Announcement", "announcement.announcement": "Announcement",
"annual_report.announcement.action_build": "Build my Wrapstodon", "annual_report.announcement.action_build": "Build my Wrapstodon",
"annual_report.announcement.action_dismiss": "No thanks",
"annual_report.announcement.action_view": "View my Wrapstodon", "annual_report.announcement.action_view": "View my Wrapstodon",
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.", "annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
"annual_report.announcement.title": "Wrapstodon {year} has arrived", "annual_report.announcement.title": "Wrapstodon {year} has arrived",
"annual_report.nav_item.badge": "New",
"annual_report.shared_page.donate": "Donate",
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
"annual_report.shared_page.sign_up": "Sign up",
"annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.", "annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.", "annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.name": "The Archer", "annual_report.summary.archetype.booster.name": "The Archer",
@@ -138,6 +143,7 @@
"annual_report.summary.archetype.title_public": "{name}'s archetype", "annual_report.summary.archetype.title_public": "{name}'s archetype",
"annual_report.summary.archetype.title_self": "Your archetype", "annual_report.summary.archetype.title_self": "Your archetype",
"annual_report.summary.close": "Close", "annual_report.summary.close": "Close",
"annual_report.summary.copy_link": "Copy link",
"annual_report.summary.followers.new_followers": "{count, plural, one {new follower} other {new followers}}", "annual_report.summary.followers.new_followers": "{count, plural, one {new follower} other {new followers}}",
"annual_report.summary.highlighted_post.boost_count": "This post was boosted {count, plural, one {once} other {# times}}.", "annual_report.summary.highlighted_post.boost_count": "This post was boosted {count, plural, one {once} other {# times}}.",
"annual_report.summary.highlighted_post.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.", "annual_report.summary.highlighted_post.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.",
@@ -146,11 +152,13 @@
"annual_report.summary.most_used_app.most_used_app": "most used app", "annual_report.summary.most_used_app.most_used_app": "most used app",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag", "annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag",
"annual_report.summary.most_used_hashtag.used_count": "You included this hashtag in {count, plural, one {one post} other {# posts}}.", "annual_report.summary.most_used_hashtag.used_count": "You included this hashtag in {count, plural, one {one post} other {# posts}}.",
"annual_report.summary.most_used_hashtag.used_count_public": "{name} included this hashtag in {count, plural, one {one post} other {# posts}}.",
"annual_report.summary.new_posts.new_posts": "new posts", "annual_report.summary.new_posts.new_posts": "new posts",
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>", "annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.", "annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
"annual_report.summary.share_elsewhere": "Share elsewhere",
"annual_report.summary.share_message": "I got the {archetype} archetype!", "annual_report.summary.share_message": "I got the {archetype} archetype!",
"annual_report.summary.title": "Wrapstodon {year}", "annual_report.summary.share_on_mastodon": "Share on Mastodon",
"attachments_list.unprocessed": "(unprocessed)", "attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio", "audio.hide": "Hide audio",
"block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.", "block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.",

View File

@@ -16,9 +16,9 @@ export interface TimeSeriesMonth {
} }
export interface TopStatuses { export interface TopStatuses {
by_reblogs: number; by_reblogs: string;
by_favourites: number; by_favourites: string;
by_replies: number; by_replies: string;
} }
export type Archetype = export type Archetype =

View File

@@ -5,7 +5,6 @@ import {
importFetchedAccounts, importFetchedAccounts,
importFetchedStatuses, importFetchedStatuses,
} from '@/mastodon/actions/importer'; } from '@/mastodon/actions/importer';
import { insertIntoTimeline } from '@/mastodon/actions/timelines';
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import { import {
apiGetAnnualReport, apiGetAnnualReport,
@@ -20,8 +19,6 @@ import {
createDataLoadingThunk, createDataLoadingThunk,
} from '../../store/typed_functions'; } from '../../store/typed_functions';
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
interface AnnualReportState { interface AnnualReportState {
state?: ApiAnnualReportState; state?: ApiAnnualReportState;
report?: AnnualReport; report?: AnnualReport;
@@ -63,18 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
// This kicks everything off, and is called after fetching the server info. // This kicks everything off, and is called after fetching the server info.
export const checkAnnualReport = createAppThunk( export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
async (_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const year = selectWrapstodonYear(getState());
if (!year) { if (!year) {
return; return;
} }
const state = await dispatch(fetchReportState()); void dispatch(fetchReportState());
if (
state.meta.requestStatus === 'fulfilled' &&
state.payload !== 'ineligible'
) {
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
}
}, },
); );

View File

@@ -1,6 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { timelineDelete } from 'mastodon/actions/timelines_typed'; import { timelineDelete, isNonStatusId } from 'mastodon/actions/timelines_typed';
import { import {
blockAccountSuccess, blockAccountSuccess,
@@ -19,7 +19,6 @@ import {
TIMELINE_MARK_AS_PARTIAL, TIMELINE_MARK_AS_PARTIAL,
TIMELINE_INSERT, TIMELINE_INSERT,
TIMELINE_GAP, TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
disconnectTimeline, disconnectTimeline,
} from '../actions/timelines'; } from '../actions/timelines';
import { compareId } from '../compare_id'; import { compareId } from '../compare_id';
@@ -36,7 +35,6 @@ const initialTimeline = ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
}); });
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because: // This method is pretty tricky because:
@@ -69,20 +67,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
// First, find the furthest (if properly sorted, oldest) item in the timeline that is // First, find the furthest (if properly sorted, oldest) item in the timeline that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap. // newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item. // Start the gap *after* that item.
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1; const lastIndex = oldIds.findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.last()) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
// is newer than the most recent fetched one, as it delimits a section comprised of only // is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newIds` (or that were deleted from the server, so should be removed // items older or within `newIds` (or that were deleted from the server, so should be removed
// anyway). // anyway).
// Stop the gap *after* that item. // Stop the gap *after* that item.
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1; const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.first()) > 0) + 1;
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => { let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
// It is possible, though unlikely, that the slice we are replacing contains items older // It is possible, though unlikely, that the slice we are replacing contains items older
// than the elements we got from the API. Get them and add them back at the back of the // than the elements we got from the API. Get them and add them back at the back of the
// slice. // slice.
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0); const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isNonStatusId(id) && compareId(id, newIds.last()) < 0);
insertedIds.union(olderIds); insertedIds.union(olderIds);
// Make sure we aren't inserting duplicates // Make sure we aren't inserting duplicates

View File

@@ -1,51 +0,0 @@
export default class Settings {
constructor(keyBase = null) {
this.keyBase = keyBase;
}
generateKey(id) {
return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
}
set(id, data) {
const key = this.generateKey(id);
try {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch {
return null;
}
}
get(id) {
const key = this.generateKey(id);
try {
const rawData = localStorage.getItem(key);
return JSON.parse(rawData);
} catch {
return null;
}
}
remove(id) {
const data = this.get(id);
if (data) {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch {
// ignore if the key is not found
}
}
return data;
}
}
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
export const tagHistory = new Settings('mastodon_tag_history');
export const bannerSettings = new Settings('mastodon_banner_settings');
export const searchHistory = new Settings('mastodon_search_history');
export const playerSettings = new Settings('mastodon_player');

View File

@@ -0,0 +1,68 @@
import type { RecentSearch } from './models/search';
export class Settings<T extends Record<string, unknown>> {
keyBase: string | null;
constructor(keyBase: string | null = null) {
this.keyBase = keyBase;
}
private generateKey(id: string | number | symbol): string {
const idStr = typeof id === 'string' ? id : String(id);
return this.keyBase ? [this.keyBase, `id${idStr}`].join('.') : idStr;
}
set<K extends keyof T>(id: K, data: T[K]): T[K] | null {
const key = this.generateKey(id);
try {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch {
return null;
}
}
get<K extends keyof T>(id: K): T[K] | null {
const key = this.generateKey(id);
try {
const rawData = localStorage.getItem(key);
if (rawData === null) return null;
return JSON.parse(rawData) as T[K];
} catch {
return null;
}
}
remove<K extends keyof T>(id: K): T[K] | null {
const data = this.get(id);
if (data !== null) {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch {
// ignore if the key is not found
}
}
return data;
}
}
export const pushNotificationsSetting = new Settings<
Record<string, { alerts: unknown }>
>('mastodon_push_notification_data');
export const tagHistory = new Settings<Record<string, string[]>>(
'mastodon_tag_history',
);
export const bannerSettings = new Settings<Record<string, boolean>>(
'mastodon_banner_settings',
);
export const searchHistory = new Settings<Record<string, RecentSearch[]>>(
'mastodon_search_history',
);
export const playerSettings = new Settings<{ volume: number; muted: boolean }>(
'mastodon_player',
);
export const wrapstodonSettings = new Settings<
Record<string, { archetypeRevealed: boolean }>
>('wrapstodon');

View File

@@ -14,3 +14,9 @@
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>; Partial<Pick<T, K>>;
export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
export type AnyFunction = (...args: never) => unknown;

View File

@@ -6,7 +6,7 @@
html { html {
@include base.palette; @include base.palette;
&[data-user-theme='system'] { &:where([data-user-theme='system']) {
color-scheme: dark light; color-scheme: dark light;
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View File

@@ -1,4 +1,4 @@
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap, List } from 'immutable';
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships'; import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
import type { ApiStatusJSON } from '@/mastodon/api_types/statuses'; import type { ApiStatusJSON } from '@/mastodon/api_types/statuses';
@@ -7,6 +7,7 @@ import type {
UnicodeEmojiData, UnicodeEmojiData,
} from '@/mastodon/features/emoji/types'; } from '@/mastodon/features/emoji/types';
import { createAccountFromServerJSON } from '@/mastodon/models/account'; import { createAccountFromServerJSON } from '@/mastodon/models/account';
import type { AnnualReport } from '@/mastodon/models/annual_report';
import type { Status } from '@/mastodon/models/status'; import type { Status } from '@/mastodon/models/status';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
@@ -75,16 +76,18 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
mentions: [], mentions: [],
tags: [], tags: [],
emojis: [], emojis: [],
content: '<p>This is a test status.</p>', contentHtml: '<p>This is a test status.</p>',
...data, ...data,
}); });
export const statusFactoryState = ( export const statusFactoryState = (
options: FactoryOptions<ApiStatusJSON> = {}, options: FactoryOptions<ApiStatusJSON> = {},
) => ) =>
ImmutableMap<string, unknown>( ImmutableMap<string, unknown>({
statusFactory(options) as unknown as Record<string, unknown>, ...(statusFactory(options) as unknown as Record<string, unknown>),
) as unknown as Status; account: options.account?.id ?? '1',
tags: List(options.tags),
}) as unknown as Status;
export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({ export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
id, id,
@@ -130,3 +133,119 @@ export function customEmojiFactory(
...data, ...data,
}; };
} }
interface AnnualReportState {
state: 'available';
report: AnnualReport;
}
interface AnnualReportFactoryOptions {
account_id?: string;
status_id?: string;
archetype?: AnnualReport['data']['archetype'];
year?: number;
top_hashtag?: AnnualReport['data']['top_hashtags'][0];
without_posts?: boolean;
}
export function annualReportFactory({
account_id = '1',
status_id = '1',
archetype = 'lurker',
year,
top_hashtag,
without_posts = false,
}: AnnualReportFactoryOptions = {}): AnnualReportState {
return {
state: 'available',
report: {
schema_version: 2,
share_url: '#',
account_id,
year: year ?? 2025,
data: {
archetype,
time_series: [
{
month: 1,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 2,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 3,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 4,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 5,
statuses: without_posts ? 0 : 1,
followers: 1,
following: 3,
},
{
month: 6,
statuses: without_posts ? 0 : 7,
followers: 1,
following: 0,
},
{
month: 7,
statuses: without_posts ? 0 : 2,
followers: 0,
following: 0,
},
{
month: 8,
statuses: without_posts ? 0 : 2,
followers: 0,
following: 0,
},
{
month: 9,
statuses: without_posts ? 0 : 11,
followers: 0,
following: 1,
},
{
month: 10,
statuses: without_posts ? 0 : 12,
followers: 0,
following: 1,
},
{
month: 11,
statuses: without_posts ? 0 : 6,
followers: 0,
following: 1,
},
{
month: 12,
statuses: without_posts ? 0 : 4,
followers: 0,
following: 0,
},
],
top_hashtags: top_hashtag ? [top_hashtag] : [],
top_statuses: {
by_reblogs: status_id,
by_replies: status_id,
by_favourites: status_id,
},
},
},
};
}

View File

@@ -3,29 +3,24 @@
class AnnualReport::TimeSeries < AnnualReport::Source class AnnualReport::TimeSeries < AnnualReport::Source
def generate def generate
{ {
time_series: (1..12).map do |month| time_series: [
{ {
month: month, month: 12,
statuses: statuses_per_month[month] || 0, statuses: statuses_this_year,
following: following_per_month[month] || 0, followers: followers_this_year,
followers: followers_per_month[month] || 0, },
} ],
end,
} }
end end
private private
def statuses_per_month def statuses_this_year
@statuses_per_month ||= report_statuses.group(:period).pluck(date_part_month.as('period'), Arel.star.count).to_h @statuses_this_year ||= report_statuses.count
end end
def following_per_month def followers_this_year
@following_per_month ||= annual_relationships_by_month(@account.active_relationships) @followers_this_year ||= @account.passive_relationships.where(created_in_year, @year).count
end
def followers_per_month
@followers_per_month ||= annual_relationships_by_month(@account.passive_relationships)
end end
def date_part_month def date_part_month
@@ -34,14 +29,6 @@ class AnnualReport::TimeSeries < AnnualReport::Source
SQL SQL
end end
def annual_relationships_by_month(relationships)
relationships
.where(created_in_year, @year)
.group(:period)
.pluck(date_part_month.as('period'), Arel.star.count)
.to_h
end
def created_in_year def created_in_year
Arel.sql(<<~SQL.squish) Arel.sql(<<~SQL.squish)
DATE_PART('year', created_at) = ? DATE_PART('year', created_at) = ?

View File

@@ -5,14 +5,14 @@ class AnnualReport::TopStatuses < AnnualReport::Source
{ {
top_statuses: { top_statuses: {
by_reblogs: status_identifier(most_reblogged_status), by_reblogs: status_identifier(most_reblogged_status),
by_favourites: status_identifier(most_favourited_status), by_favourites: nil,
by_replies: status_identifier(most_replied_status), by_replies: nil,
}, },
} }
end end
def eligible? def eligible?
report_statuses.public_visibility.exists? report_statuses.distributable_visibility.exists?
end end
private private
@@ -43,7 +43,7 @@ class AnnualReport::TopStatuses < AnnualReport::Source
def base_scope def base_scope
report_statuses report_statuses
.public_visibility .distributable_visibility
.joins(:status_stat) .joins(:status_stat)
end end
end end

View File

@@ -451,6 +451,10 @@ class Account < ApplicationRecord
save! save!
end end
def featureable?
local? && discoverable?
end
private private
def prepare_contents def prepare_contents

View File

@@ -32,6 +32,8 @@ class CollectionItem < ApplicationRecord
validates :account, presence: true, if: :accepted? validates :account, presence: true, if: :accepted?
validates :object_uri, presence: true, if: -> { account.nil? } validates :object_uri, presence: true, if: -> { account.nil? }
before_validation :set_position, on: :create
scope :ordered, -> { order(position: :asc) } scope :ordered, -> { order(position: :asc) }
scope :with_accounts, -> { includes(account: [:account_stat, :user]) } scope :with_accounts, -> { includes(account: [:account_stat, :user]) }
scope :not_blocked_by, ->(account) { where.not(accounts: { id: account.blocking }) } scope :not_blocked_by, ->(account) { where.not(accounts: { id: account.blocking }) }
@@ -39,4 +41,12 @@ class CollectionItem < ApplicationRecord
def local_item_with_remote_account? def local_item_with_remote_account?
local? && account&.remote? local? && account&.remote?
end end
private
def set_position
return if position_changed?
self.position = self.class.where(collection_id:).maximum(:position).to_i + 1
end
end end

View File

@@ -64,4 +64,8 @@ class AccountPolicy < ApplicationPolicy
def review? def review?
role.can?(:manage_taxonomies) role.can?(:manage_taxonomies)
end end
def feature?
record.featureable? && !current_account.blocking?(record) && !record.blocking?(current_account)
end
end end

View File

@@ -3,7 +3,11 @@
class REST::CollectionItemSerializer < ActiveModel::Serializer class REST::CollectionItemSerializer < ActiveModel::Serializer
delegate :accepted?, to: :object delegate :accepted?, to: :object
attributes :position, :state attributes :id, :position, :state
belongs_to :account, serializer: REST::AccountSerializer, if: :accepted? belongs_to :account, serializer: REST::AccountSerializer, if: :accepted?
def id
object.id.to_s
end
end end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddAccountToCollectionService
def call(collection, account)
raise ArgumentError unless collection.local?
@collection = collection
@account = account
raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@collection.account, @account).feature?
create_collection_item
end
private
def create_collection_item
@collection.collection_items.create!(
account: @account,
state: :accepted
)
end
end

View File

@@ -0,0 +1,4 @@
- description = t('wrapstodon.description', name: display_name(account))
%meta{ name: 'description', content: description }/
= opengraph 'og:description', description

View File

@@ -0,0 +1,6 @@
- if %w(lurker booster pollster replier oracle).include?(report.data['archetype'])
= opengraph 'og:image', frontend_asset_url("images/archetypes/previews/#{report.data['archetype']}.jpg")
= opengraph 'og:image:type', 'image/jpeg'
= opengraph 'og:image:width', 1200
= opengraph 'og:image:height', 630
= opengraph 'twitter:card', 'summary_large_image'

View File

@@ -4,8 +4,14 @@
%meta{ name: 'robots', content: 'noindex, noarchive' }/ %meta{ name: 'robots', content: 'noindex, noarchive' }/
= opengraph 'og:site_name', site_title = opengraph 'og:site_name', site_title
= opengraph 'og:type', 'article'
= opengraph 'og:title', t('wrapstodon.title', name: display_name(@account), year: @generated_annual_report.year)
= opengraph 'profile:username', acct(@account)[1..] = opengraph 'profile:username', acct(@account)[1..]
= render 'og_description', account: @account
= render 'og_image', report: @generated_annual_report
= render_initial_state
= flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous' = flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
- content_for :html_classes, 'theme-dark' - content_for :html_classes, 'theme-dark'

View File

@@ -7,6 +7,8 @@ en:
hosted_on: Mastodon hosted on %{domain} hosted_on: Mastodon hosted on %{domain}
title: About title: About
accounts: accounts:
errors:
cannot_be_added_to_collections: This account cannot be added to collections.
followers: followers:
one: Follower one: Follower
other: Followers other: Followers
@@ -2187,4 +2189,5 @@ en:
otp_required: To use security keys please enable two-factor authentication first. otp_required: To use security keys please enable two-factor authentication first.
registered_on: Registered on %{date} registered_on: Registered on %{date}
wrapstodon: wrapstodon:
description: See how %{name} used Mastodon this year!
title: Wrapstodon %{year} for %{name} title: Wrapstodon %{year} for %{name}

Some files were not shown because too many files have changed in this diff Show More