mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-15 16:59:41 +00:00
Compare commits
58 Commits
cd71fdcdff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3afd087b5 | ||
|
|
235af71f85 | ||
|
|
6734fd206e | ||
|
|
cb1d1e289e | ||
|
|
acf583d374 | ||
|
|
8fad8681ab | ||
|
|
e7c383251b | ||
|
|
febd6241bf | ||
|
|
aa45a5fa83 | ||
|
|
6503287c2d | ||
|
|
4af8e83c8a | ||
|
|
861202fd08 | ||
|
|
6821b70796 | ||
|
|
3cc4b59b41 | ||
|
|
1e67567d8f | ||
|
|
b72b507584 | ||
|
|
8748f0812d | ||
|
|
e206b0d0de | ||
|
|
571c93c563 | ||
|
|
10f232ca08 | ||
|
|
88c0f52e99 | ||
|
|
a56b739c68 | ||
|
|
183a42a5ee | ||
|
|
303a5478af | ||
|
|
dfbf908870 | ||
|
|
aa067370d8 | ||
|
|
5e0db46b2a | ||
|
|
c06eb371e6 | ||
|
|
53617cef5a | ||
|
|
d730f6b0c5 | ||
|
|
addeb28292 | ||
|
|
5e3387539e | ||
|
|
4323963053 | ||
|
|
5651900b89 | ||
|
|
d1b996b7e3 | ||
|
|
fed26a41fa | ||
|
|
37d309bcaf | ||
|
|
d25f672c50 | ||
|
|
15c9088761 | ||
|
|
da1505a495 | ||
|
|
d1f690f50c | ||
|
|
8b418b84d0 | ||
|
|
f817300d8d | ||
|
|
35a89a0173 | ||
|
|
b5721dbd4a | ||
|
|
38f623eee7 | ||
|
|
17ba99e5de | ||
|
|
da2b75bdcd | ||
|
|
adf8a3601d | ||
|
|
d6f2a3ac8d | ||
|
|
c42b9f6996 | ||
|
|
76184c998c | ||
|
|
8137ce87ce | ||
|
|
37426288d9 | ||
|
|
801fee7593 | ||
|
|
6838497fe8 | ||
|
|
7b8a5d42f1 | ||
|
|
91500a7f53 |
42
.github/workflows/build-releases.yml
vendored
42
.github/workflows/build-releases.yml
vendored
@@ -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}}
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
41
app/controllers/api/v1_alpha/collection_items_controller.rb
Normal file
41
app/controllers/api/v1_alpha/collection_items_controller.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
42
app/javascript/flavours/glitch/hooks/useDismissible.ts
Normal file
42
app/javascript/flavours/glitch/hooks/useDismissible.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
|
||||||
68
app/javascript/flavours/glitch/settings.ts
Normal file
68
app/javascript/flavours/glitch/settings.ts
Normal 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');
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
100
app/javascript/fonts/silkscreen-wrapstodon/OFL.txt
Normal file
100
app/javascript/fonts/silkscreen-wrapstodon/OFL.txt
Normal 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.
BIN
app/javascript/images/archetypes/previews/booster.jpg
Normal file
BIN
app/javascript/images/archetypes/previews/booster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
app/javascript/images/archetypes/previews/lurker.jpg
Normal file
BIN
app/javascript/images/archetypes/previews/lurker.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
app/javascript/images/archetypes/previews/oracle.jpg
Normal file
BIN
app/javascript/images/archetypes/previews/oracle.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
app/javascript/images/archetypes/previews/pollster.jpg
Normal file
BIN
app/javascript/images/archetypes/previews/pollster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
app/javascript/images/archetypes/previews/replier.jpg
Normal file
BIN
app/javascript/images/archetypes/previews/replier.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
3
app/javascript/images/icons/icon_planet.svg
Normal file
3
app/javascript/images/icons/icon_planet.svg
Normal 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 |
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
55
app/javascript/mastodon/features/annual_report/nav_item.tsx
Normal file
55
app/javascript/mastodon/features/annual_report/nav_item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal file
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
|
||||||
68
app/javascript/mastodon/settings.ts
Normal file
68
app/javascript/mastodon/settings.ts
Normal 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');
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) = ?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -451,6 +451,10 @@ class Account < ApplicationRecord
|
|||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def featureable?
|
||||||
|
local? && discoverable?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def prepare_contents
|
def prepare_contents
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
23
app/services/add_account_to_collection_service.rb
Normal file
23
app/services/add_account_to_collection_service.rb
Normal 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
|
||||||
4
app/views/wrapstodon/_og_description.html.haml
Normal file
4
app/views/wrapstodon/_og_description.html.haml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- description = t('wrapstodon.description', name: display_name(account))
|
||||||
|
|
||||||
|
%meta{ name: 'description', content: description }/
|
||||||
|
= opengraph 'og:description', description
|
||||||
6
app/views/wrapstodon/_og_image.html.haml
Normal file
6
app/views/wrapstodon/_og_image.html.haml
Normal 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'
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user