mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-16 01:09:55 +00:00
Compare commits
44 Commits
8b418b84d0
...
i18n/crowd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7d96419c | ||
|
|
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 | ||
|
|
da2b75bdcd | ||
|
|
adf8a3601d |
42
.github/workflows/build-releases.yml
vendored
42
.github/workflows/build-releases.yml
vendored
@@ -9,7 +9,44 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
check-latest-stable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
latest: ${{ steps.check.outputs.is_latest_stable }}
|
||||
steps:
|
||||
# Repository needs to be cloned to list branches
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check latest stable
|
||||
shell: bash
|
||||
id: check
|
||||
run: |
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then
|
||||
current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "tag $ref is not semver"
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \
|
||||
| sed -E 's#^origin/stable-##' \
|
||||
| sort -Vr \
|
||||
| head -n1)
|
||||
|
||||
if [[ "$current" == "$latest" ]]; then
|
||||
echo "is_latest_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-image:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: Dockerfile
|
||||
@@ -20,13 +57,14 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
secrets: inherit
|
||||
|
||||
build-image-streaming:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: streaming/Dockerfile
|
||||
@@ -37,7 +75,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
@@ -53,7 +53,7 @@ GEM
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_model_serializers (0.10.15)
|
||||
active_model_serializers (0.10.16)
|
||||
actionpack (>= 4.1)
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
@@ -481,7 +481,7 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
nokogiri (~> 1.12)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-rails_csrf_protection (2.0.0)
|
||||
omniauth-rails_csrf_protection (2.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
@@ -839,7 +839,8 @@ GEM
|
||||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (5.6.0)
|
||||
stoplight (5.7.0)
|
||||
concurrent-ruby
|
||||
zeitwerk
|
||||
stringio (3.1.8)
|
||||
strong_migrations (2.5.1)
|
||||
|
||||
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
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export const hydrateSearch = createAppAsyncThunk(
|
||||
'search/hydrate',
|
||||
(_args, { dispatch, getState }) => {
|
||||
const me = getState().meta.get('me') as string;
|
||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||
const history = searchHistory.get(me);
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
|
||||
import api, { getLinks } from 'flavours/glitch/api';
|
||||
import { compareId } from 'flavours/glitch/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
|
||||
@@ -33,7 +32,6 @@ export const TIMELINE_GAP = null;
|
||||
export const TIMELINE_NON_STATUS_MARKERS = [
|
||||
TIMELINE_GAP,
|
||||
TIMELINE_SUGGESTIONS,
|
||||
TIMELINE_WRAPSTODON,
|
||||
];
|
||||
|
||||
export const loadPending = timeline => ({
|
||||
@@ -143,7 +141,6 @@ export function expandTimeline(timelineId, path, params = {}) {
|
||||
|
||||
if (timelineId === 'home') {
|
||||
dispatch(submitMarkers());
|
||||
dispatch(reinsertAnnualReport())
|
||||
}
|
||||
} catch(error) {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { bannerSettings } from 'flavours/glitch/settings';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { useDismissible } from '../hooks/useDismissible';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
@@ -16,48 +14,12 @@ const messages = defineMessages({
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function useDismissableBannerState({ id }: Props) {
|
||||
// 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,
|
||||
}) => {
|
||||
export const DismissableBanner: FC<Props> = ({ id, children }) => {
|
||||
const intl = useIntl();
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(id);
|
||||
|
||||
if (wasDismissed) {
|
||||
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 { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
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,
|
||||
@@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id: DISMISSABLE_BANNER_ID,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
|
||||
|
||||
const shouldShowHint = !wasDismissed && canShowHint;
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
|
||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines';
|
||||
import { RegenerationIndicator } from 'flavours/glitch/components/regeneration_indicator';
|
||||
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';
|
||||
|
||||
@@ -68,10 +66,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
return (
|
||||
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
|
||||
);
|
||||
case TIMELINE_WRAPSTODON:
|
||||
return (
|
||||
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
|
||||
)
|
||||
case TIMELINE_GAP:
|
||||
return (
|
||||
<LoadGap
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
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 '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/AnnualReportAnnouncement',
|
||||
component: AnnualReportAnnouncement,
|
||||
args: {
|
||||
hasData: false,
|
||||
isLoading: false,
|
||||
year: '2025',
|
||||
onRequestBuild: fn(),
|
||||
onOpen: fn(),
|
||||
type Props = OmitValueType<
|
||||
// We can't use the name 'state' here because it's reserved for overriding Redux state.
|
||||
Omit<AnnualReportAnnouncementProps, 'state'> & {
|
||||
reportState: AnnualReportAnnouncementProps['state'];
|
||||
},
|
||||
} 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;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => <AnnualReportAnnouncement {...args} />,
|
||||
};
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
reportState: 'generating',
|
||||
},
|
||||
render: Default.render,
|
||||
};
|
||||
|
||||
export const WithData: Story = {
|
||||
args: {
|
||||
hasData: true,
|
||||
reportState: 'available',
|
||||
},
|
||||
render: Default.render,
|
||||
};
|
||||
|
||||
@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export const AnnualReportAnnouncement: React.FC<{
|
||||
export interface AnnualReportAnnouncementProps {
|
||||
year: string;
|
||||
hasData: boolean;
|
||||
isLoading: boolean;
|
||||
state: Exclude<ApiAnnualReportState, 'ineligible'>;
|
||||
onRequestBuild: () => void;
|
||||
onOpen: () => void;
|
||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
||||
onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then.
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
values={{ year }}
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.description'
|
||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||
/>
|
||||
</p>
|
||||
{hasData ? (
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
values={{ year }}
|
||||
tagName='h2'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.description'
|
||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||
tagName='p'
|
||||
/>
|
||||
{state === 'available' ? (
|
||||
<Button onClick={onOpen}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_view'
|
||||
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<Button loading={isLoading} onClick={onRequestBuild}>
|
||||
<Button loading={state === 'generating'} onClick={onRequestBuild}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_build'
|
||||
defaultMessage='Build my Wrapstodon'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{state === 'eligible' && (
|
||||
<Button onClick={onDismiss} plain className={styles.closeButton}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_dismiss'
|
||||
defaultMessage='No thanks'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
|
||||
var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
@@ -26,4 +28,15 @@
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
:global(.modal-root__modal) & {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
import { me } from '@/flavours/glitch/initial_state';
|
||||
import type { Account } from '@/flavours/glitch/models/account';
|
||||
import type {
|
||||
AnnualReport,
|
||||
Archetype as ArchetypeData,
|
||||
} from '@/flavours/glitch/models/annual_report';
|
||||
import { wrapstodonSettings } from '@/flavours/glitch/settings';
|
||||
import booster from '@/images/archetypes/booster.png';
|
||||
import lurker from '@/images/archetypes/lurker.png';
|
||||
import oracle from '@/images/archetypes/oracle.png';
|
||||
@@ -117,9 +119,16 @@ export const Archetype: React.FC<{
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isSelfView = context === 'modal';
|
||||
|
||||
const [isRevealed, setIsRevealed] = useState(!isSelfView);
|
||||
const [isRevealed, setIsRevealed] = useState(
|
||||
() =>
|
||||
!isSelfView ||
|
||||
(me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true),
|
||||
);
|
||||
const reveal = useCallback(() => {
|
||||
setIsRevealed(true);
|
||||
if (me) {
|
||||
wrapstodonSettings.set(me, { archetypeRevealed: true });
|
||||
}
|
||||
wrapperRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
@@ -128,7 +137,8 @@ export const Archetype: React.FC<{
|
||||
? archetypeSelfDescriptions
|
||||
: 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 (
|
||||
<div
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
@typescript-eslint/no-unsafe-member-access,
|
||||
@typescript-eslint/no-unsafe-call */
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { InterceptStatusClicks } from 'flavours/glitch/components/status/intercept_status_clicks';
|
||||
import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted';
|
||||
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
|
||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||
@@ -29,6 +33,24 @@ export const HighlightedPost: React.FC<{
|
||||
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) {
|
||||
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
|
||||
}
|
||||
@@ -72,7 +94,9 @@ export const HighlightedPost: React.FC<{
|
||||
{context === 'modal' && <p>{label}</p>}
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager showActions={false} id={statusId} />
|
||||
<InterceptStatusClicks onPreventedClick={handleClick}>
|
||||
<StatusQuoteManager showActions={false} id={statusId} />
|
||||
</InterceptStatusClicks>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,18 +10,17 @@ $mobile-breakpoint: 540px;
|
||||
}
|
||||
|
||||
.modalWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
padding-inline: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loading-indicator .circular-progress {
|
||||
@@ -50,37 +49,51 @@ $mobile-breakpoint: 540px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
--gradient-strength: 0.4;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
max-width: 600px;
|
||||
padding: 24px;
|
||||
padding-top: 40px;
|
||||
contain: layout;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
pointer-events: all;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
background:
|
||||
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
||||
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
||||
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
||||
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%),
|
||||
radial-gradient(at 80% 91%, #16dae499 0, transparent 50%)
|
||||
radial-gradient(
|
||||
at 10% 27%,
|
||||
rgba(83, 12, 154, var(--gradient-strength)) 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);
|
||||
border-radius: 40px;
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
padding-inline: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: inherit;
|
||||
border-radius: inherit;
|
||||
filter: blur(20px);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +105,7 @@ $mobile-breakpoint: 540px;
|
||||
font-family: silkscreen-wrapstodon, monospace;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
padding-inline: 40px; // Prevent overlap with close button
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
@@ -116,7 +129,7 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: rgb(from var(--color-bg-primary) r g b / 60%);
|
||||
box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
|
||||
@@ -150,7 +163,6 @@ $mobile-breakpoint: 540px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
@@ -162,8 +174,12 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
color: #c2c8ff;
|
||||
color: var(--color-text-brand-soft);
|
||||
font-weight: 500;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.statLarge {
|
||||
@@ -185,7 +201,7 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.mostBoostedPost {
|
||||
padding: 0;
|
||||
padding-top: 8px;
|
||||
padding-top: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -260,7 +276,7 @@ $mobile-breakpoint: 540px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
max-width: 460px;
|
||||
@@ -316,3 +332,20 @@ $mobile-breakpoint: 540px;
|
||||
left: 0;
|
||||
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 type { FC } from 'react';
|
||||
|
||||
import { defineMessage, useIntl } from 'react-intl';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
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 { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
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 { Archetype } from './archetype';
|
||||
@@ -23,10 +27,18 @@ import { NewPosts } from './new_posts';
|
||||
|
||||
const moduleClassNames = classNames.bind(styles);
|
||||
|
||||
export const shareMessage = defineMessage({
|
||||
id: 'annual_report.summary.share_message',
|
||||
defaultMessage: 'I got the {archetype} archetype!',
|
||||
});
|
||||
const accountSelector = createAppSelector(
|
||||
[(state) => state.accounts, (state) => state.annualReport.report],
|
||||
(accounts, report) => {
|
||||
if (me) {
|
||||
return accounts.get(me);
|
||||
}
|
||||
if (report?.schema_version === 2) {
|
||||
return accounts.get(report.account_id);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
context = 'standalone',
|
||||
@@ -34,15 +46,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const report = useAppSelector((state) => state.annualReport.report);
|
||||
const account = useAppSelector((state) => {
|
||||
if (me) {
|
||||
return state.accounts.get(me);
|
||||
}
|
||||
if (report?.schema_version === 2) {
|
||||
return state.accounts.get(report.account_id);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const account = useAppSelector(accountSelector);
|
||||
|
||||
const close = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
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 { AnnualReportAnnouncement } from './announcement';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const AnnualReportModal: React.FC<{
|
||||
@@ -12,15 +21,66 @@ const AnnualReportModal: React.FC<{
|
||||
onChangeBackgroundColor('var(--color-bg-media-base)');
|
||||
}, [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 (
|
||||
// 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
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
<AnnualReport context='modal' />
|
||||
{!showAnnouncement ? (
|
||||
<AnnualReport context='modal' />
|
||||
) : (
|
||||
<AnnualReportAnnouncement
|
||||
year={year.toString()}
|
||||
state={state}
|
||||
onDismiss={handleClose}
|
||||
onRequestBuild={handleBuildRequest}
|
||||
/>
|
||||
)}
|
||||
</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 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 { closeModal } from '@/flavours/glitch/actions/modal';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
|
||||
import { shareMessage } from '.';
|
||||
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 }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleShareClick = useCallback(() => {
|
||||
// Generate the share message.
|
||||
const archetypeName = intl.formatMessage(
|
||||
archetypeNames[report.data.archetype],
|
||||
);
|
||||
const shareLines = [
|
||||
intl.formatMessage(shareMessage, {
|
||||
intl.formatMessage(messages.share_message, {
|
||||
archetype: archetypeName,
|
||||
}),
|
||||
];
|
||||
@@ -37,5 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
|
||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||
}, [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,20 +0,0 @@
|
||||
.wrapper {
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
padding: 40px 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconLogo } from '@/flavours/glitch/components/logo';
|
||||
import { me } from '@/flavours/glitch/initial_state';
|
||||
|
||||
import { AnnualReport } from './index';
|
||||
import classes from './shared_page.module.css';
|
||||
import classes from './shared_page.module.scss';
|
||||
|
||||
export const WrapstodonSharedPage: FC = () => {
|
||||
return (
|
||||
@@ -16,8 +17,27 @@ export const WrapstodonSharedPage: FC = () => {
|
||||
<FormattedMessage
|
||||
id='annual_report.shared_page.footer'
|
||||
defaultMessage='Generated with {heart} by the Mastodon team'
|
||||
values={{ heart: '♥' }}
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
|
||||
import {
|
||||
generateReport,
|
||||
selectWrapstodonYear,
|
||||
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
|
||||
void dispatch(generateReport());
|
||||
}, [dispatch]);
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissible(
|
||||
`annual_report_announcement_${year}`,
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
|
||||
}, [dispatch]);
|
||||
dismiss();
|
||||
}, [dismiss, dispatch]);
|
||||
|
||||
if (!year || !state || state === 'ineligible') {
|
||||
if (!year || wasDismissed || !state || state === 'ineligible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnualReportAnnouncement
|
||||
year={year.toString()}
|
||||
hasData={state === 'available'}
|
||||
isLoading={state === 'generating'}
|
||||
state={state}
|
||||
onRequestBuild={handleBuildRequest}
|
||||
onOpen={handleOpen}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,8 +41,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
||||
};
|
||||
|
||||
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
const volume = playerSettings.get('volume') ?? 0.5;
|
||||
const muted = playerSettings.get('muted') ?? false;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted;
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CriticalUpdateBanner = () => (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<h1>
|
||||
import { criticalUpdatesPending } from '@/flavours/glitch/initial_state';
|
||||
|
||||
export const CriticalUpdateBanner: FC = () => {
|
||||
if (!criticalUpdatesPending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.title'
|
||||
defaultMessage='Critical security update available!'
|
||||
tagName='h1'
|
||||
/>
|
||||
</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
|
||||
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||
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 { 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 { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { Announcements } from './components/announcements';
|
||||
import { AnnualReportTimeline } from '../annual_report/timeline';
|
||||
|
||||
const messages = defineMessages({
|
||||
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 pinned = !!columnId;
|
||||
const { signedIn } = this.props.identity;
|
||||
const banners = [];
|
||||
const banners = [
|
||||
<CriticalUpdateBanner key='critical-update-banner' />,
|
||||
<AnnualReportTimeline key='annual-report' />
|
||||
];
|
||||
|
||||
let announcementsButton;
|
||||
|
||||
@@ -147,10 +150,6 @@ class HomeTimeline extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (criticalUpdatesPending) {
|
||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
|
||||
@@ -51,6 +51,8 @@ import { canViewFeed } from 'flavours/glitch/permissions';
|
||||
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||
|
||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||
import { ListPanel } from './components/list_panel';
|
||||
@@ -318,6 +320,8 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<AnnualReportNavItem />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
@@ -139,8 +139,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
||||
};
|
||||
|
||||
const restoreVolume = (video: HTMLVideoElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
const volume = playerSettings.get('volume') ?? 0.5;
|
||||
const muted = playerSettings.get('muted') ?? false;
|
||||
|
||||
video.volume = volume;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,23 @@
|
||||
{
|
||||
"about.fork_disclaimer": "Glitch-soc er fri programvare med åpen kildekode, forgrenet fra Mastodon.",
|
||||
"compose.content-type.html": "HTML",
|
||||
"compose.content-type.html_meta": "Formater innleggene dine med HTML",
|
||||
"compose.content-type.markdown": "Markdown",
|
||||
"compose.content-type.markdown_meta": "Formater innleggene dine med Markdown",
|
||||
"compose.content-type.plain": "Ren tekst",
|
||||
"compose.disable_threaded_mode": "Deaktiver trådmodus",
|
||||
"compose.enable_threaded_mode": "Aktiver trådmodus",
|
||||
"confirmations.deprecated_settings.confirm": "Bruk Mastodon-innstillinger",
|
||||
"federation.federated.short": "Føderert",
|
||||
"federation.local_only.short": "Kun lokalt",
|
||||
"navigation_bar.app_settings": "App-innstillinger",
|
||||
"settings.always_show_spoilers_field": "Aktiver alltid feltet for innholdsvarsler",
|
||||
"settings.close": "Lukk",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
"settings.preferences": "Preferences"
|
||||
"settings.content_warnings.regexp": "Regulært uttrykk",
|
||||
"settings.pop_in_left": "Venstre",
|
||||
"settings.pop_in_player": "Aktiver flytende avspiller",
|
||||
"settings.pop_in_right": "Høyre",
|
||||
"settings.preferences": "Preferences",
|
||||
"settings.side_arm.none": "Ingen"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
{
|
||||
"settings.content_warnings": "Content warnings",
|
||||
"settings.preferences": "Preferences"
|
||||
"about.fork_disclaimer": "Glitch-soc er fri programvare med åpen kildekode, forgrenet fra Mastodon.",
|
||||
"compose.content-type.html": "HTML",
|
||||
"compose.content-type.html_meta": "Formater innleggene dine med HTML",
|
||||
"compose.content-type.markdown": "Markdown",
|
||||
"compose.content-type.markdown_meta": "Formater innleggene dine med Markdown",
|
||||
"compose.content-type.plain": "Ren tekst",
|
||||
"compose.disable_threaded_mode": "Deaktiver trådmodus",
|
||||
"compose.enable_threaded_mode": "Aktiver trådmodus",
|
||||
"confirmations.deprecated_settings.confirm": "Bruk Mastodon-innstillinger",
|
||||
"federation.federated.short": "Føderert",
|
||||
"federation.local_only.short": "Kun lokalt",
|
||||
"navigation_bar.app_settings": "App-innstillinger",
|
||||
"settings.always_show_spoilers_field": "Aktiver alltid feltet for innholdsvarsler",
|
||||
"settings.close": "Lukk",
|
||||
"settings.content_warnings": "Innholdsvarsler",
|
||||
"settings.content_warnings.regexp": "Regulært uttrykk",
|
||||
"settings.pop_in_left": "Venstre",
|
||||
"settings.pop_in_player": "Aktiver flytende avspiller",
|
||||
"settings.pop_in_right": "Høyre",
|
||||
"settings.preferences": "Brukerinnstillinger",
|
||||
"settings.side_arm.none": "Ingen"
|
||||
}
|
||||
|
||||
@@ -9,13 +9,25 @@
|
||||
"column.reblogged_by": "Inpulsionado por",
|
||||
"column_header.profile": "Perfil",
|
||||
"community.column_settings.allow_local_only": "Mostrar os toots apenas locais",
|
||||
"compose.attach.doodle": "Desenhe algo",
|
||||
"compose.change_federation": "Alterar configurações de federação",
|
||||
"compose.content-type.change": "Alterrar opções avançadas de formatação",
|
||||
"compose.content-type.html": "HTML",
|
||||
"compose.content-type.html_meta": "Formatar suas publicações usando HTML",
|
||||
"compose.content-type.markdown": "Markdown",
|
||||
"compose.content-type.markdown_meta": "Formatar suas publicações usando Markdown",
|
||||
"compose.content-type.plain": "Texto sem formatação",
|
||||
"compose.content-type.plain_meta": "Escrever sem formatação avançada",
|
||||
"confirmation_modal.do_not_ask_again": "Não pedir confirmação novamente",
|
||||
"confirmations.deprecated_settings.confirm": "Usar preferências do Mastodon",
|
||||
"confirmations.deprecated_settings.message": "Alguns dos {app_settings} específicos do dispositivo que você está usando foram substituídos por Mastodon {preferences} e serão substituídos:",
|
||||
"direct.group_by_conversations": "Agrupar por conversa",
|
||||
"favourite_modal.favourite": "Favoritar publicação?",
|
||||
"federation.federated.long": "Permitir que esta publicação alcance outros servidores",
|
||||
"federation.federated.short": "Federado",
|
||||
"federation.local_only.long": "Evitar que esta publicação alcance outros servidores",
|
||||
"federation.local_only.short": "Somente local",
|
||||
"firehose.column_settings.allow_local_only": "Exibir publicações somente locais em \"Todas\"",
|
||||
"home.column_settings.advanced": "Avançado",
|
||||
"home.column_settings.filter_regex": "Filtrar com uma expressão regular",
|
||||
"home.column_settings.show_direct": "Mostrar DMs",
|
||||
@@ -24,6 +36,7 @@
|
||||
"keyboard_shortcuts.secondary_toot": "para enviar toot usando a configuração de privacidade secundária",
|
||||
"moved_to_warning": "Esta conta foi como movida para {moved_to_link} e, portanto, pode não aceitar novos seguidores.",
|
||||
"navigation_bar.app_settings": "Configurações do aplicativo",
|
||||
"notifications.column_settings.filter_bar.show_bar": "Exibir barra de filtro",
|
||||
"settings.always_show_spoilers_field": "Sempre ativar o campo Aviso de Conteúdo",
|
||||
"settings.close": "Fechar",
|
||||
"settings.compose_box_opts": "Caixa de composição",
|
||||
@@ -37,6 +50,8 @@
|
||||
"settings.content_warnings_unfold_opts": "Opções de auto-revelar",
|
||||
"settings.deprecated_setting": "Essa configuração agora é controlada pelo {settings_page_link} do Mastodon",
|
||||
"settings.enable_content_warnings_auto_unfold": "Revelar automaticamente os avisos de conteúdo",
|
||||
"settings.fullwidth_view": "Estender colunas para preencher a largura (somente modo Desktop)",
|
||||
"settings.fullwidth_view_hint": "Estende as colunas para ocupar todo o espaço disponível.",
|
||||
"settings.general": "Geral",
|
||||
"settings.hicolor_privacy_icons": "Ícones de privacidade com cores de alto contraste",
|
||||
"settings.hicolor_privacy_icons.hint": "Exibir ícones de privacidade em cores brilhantes e facilmente distinguíveis",
|
||||
@@ -84,11 +99,14 @@
|
||||
"settings.tag_misleading_links.hint": "Acrescentar uma indicação visual com o link hospedeiro alvo a cada link que não o mencione explicitamente",
|
||||
"settings.wide_view": "Visualização ampla (apenas no Modo desktop)",
|
||||
"settings.wide_view_hint": "Estica as colunas para preencher melhor o espaço disponível.",
|
||||
"status.filtered": "Filtrado",
|
||||
"status.has_audio": "Possui um arquivo de áudio anexado",
|
||||
"status.has_pictures": "Possui uma imagem anexada",
|
||||
"status.has_preview_card": "Possui uma pré-visualização anexada",
|
||||
"status.has_video": "Possui um vídeo anexado",
|
||||
"status.hide": "Ocultar publicação",
|
||||
"status.in_reply_to": "Este toot é uma resposta",
|
||||
"status.is_poll": "Este toot é uma enquete",
|
||||
"status.local_only": "Visível apenas em sua instância"
|
||||
"status.local_only": "Visível apenas em sua instância",
|
||||
"status.show_filter_reason": "Mostrar mesmo assim"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
importFetchedAccounts,
|
||||
importFetchedStatuses,
|
||||
} from '@/flavours/glitch/actions/importer';
|
||||
import { insertIntoTimeline } from '@/flavours/glitch/actions/timelines';
|
||||
import { timelineDelete } from '@/flavours/glitch/actions/timelines_typed';
|
||||
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
|
||||
import {
|
||||
apiGetAnnualReport,
|
||||
@@ -21,8 +19,6 @@ import {
|
||||
createDataLoadingThunk,
|
||||
} from '../../store/typed_functions';
|
||||
|
||||
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
|
||||
|
||||
interface AnnualReportState {
|
||||
state?: ApiAnnualReportState;
|
||||
report?: AnnualReport;
|
||||
@@ -64,37 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
|
||||
// This kicks everything off, and is called after fetching the server info.
|
||||
export const checkAnnualReport = createAppThunk(
|
||||
`${annualReportSlice.name}/checkAnnualReport`,
|
||||
async (_arg: unknown, { dispatch, getState }) => {
|
||||
(_arg: unknown, { dispatch, getState }) => {
|
||||
const year = selectWrapstodonYear(getState());
|
||||
if (!year) {
|
||||
return;
|
||||
}
|
||||
const state = await dispatch(fetchReportState());
|
||||
if (
|
||||
state.meta.requestStatus === 'fulfilled' &&
|
||||
state.payload !== 'ineligible'
|
||||
) {
|
||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const reinsertAnnualReport = createAppThunk(
|
||||
`${annualReportSlice.name}/reinsertAnnualReport`,
|
||||
(_arg: unknown, { dispatch, getState }) => {
|
||||
dispatch(
|
||||
timelineDelete({
|
||||
statusId: TIMELINE_WRAPSTODON,
|
||||
accountId: '',
|
||||
references: [],
|
||||
reblogOf: null,
|
||||
}),
|
||||
);
|
||||
const { state } = getState().annualReport;
|
||||
if (!state || state === 'ineligible') {
|
||||
return;
|
||||
}
|
||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
||||
void dispatch(fetchReportState());
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -14,3 +14,9 @@
|
||||
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>> &
|
||||
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;
|
||||
|
||||
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',
|
||||
(_args, { dispatch, getState }) => {
|
||||
const me = getState().meta.get('me') as string;
|
||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||
const history = searchHistory.get(me);
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
|
||||
import api, { getLinks } from 'mastodon/api';
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
@@ -32,7 +31,6 @@ export const TIMELINE_GAP = null;
|
||||
export const TIMELINE_NON_STATUS_MARKERS = [
|
||||
TIMELINE_GAP,
|
||||
TIMELINE_SUGGESTIONS,
|
||||
TIMELINE_WRAPSTODON,
|
||||
];
|
||||
|
||||
export const loadPending = timeline => ({
|
||||
@@ -132,7 +130,6 @@ export function expandTimeline(timelineId, path, params = {}) {
|
||||
|
||||
if (timelineId === 'home') {
|
||||
dispatch(submitMarkers());
|
||||
dispatch(reinsertAnnualReport())
|
||||
}
|
||||
} catch(error) {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { bannerSettings } from 'mastodon/settings';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { useDismissible } from '../hooks/useDismissible';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
@@ -16,48 +14,12 @@ const messages = defineMessages({
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function useDismissableBannerState({ id }: Props) {
|
||||
// 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,
|
||||
}) => {
|
||||
export const DismissableBanner: FC<Props> = ({ id, children }) => {
|
||||
const intl = useIntl();
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(id);
|
||||
|
||||
if (wasDismissed) {
|
||||
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 { useDismissible } from '@/mastodon/hooks/useDismissible';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
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,
|
||||
@@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id: DISMISSABLE_BANNER_ID,
|
||||
});
|
||||
const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
|
||||
|
||||
const shouldShowHint = !wasDismissed && canShowHint;
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
|
||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
||||
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';
|
||||
|
||||
@@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
return (
|
||||
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
|
||||
);
|
||||
case TIMELINE_WRAPSTODON:
|
||||
return (
|
||||
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
|
||||
)
|
||||
case TIMELINE_GAP:
|
||||
return (
|
||||
<LoadGap
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
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 '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/AnnualReportAnnouncement',
|
||||
component: AnnualReportAnnouncement,
|
||||
args: {
|
||||
hasData: false,
|
||||
isLoading: false,
|
||||
year: '2025',
|
||||
onRequestBuild: fn(),
|
||||
onOpen: fn(),
|
||||
type Props = OmitValueType<
|
||||
// We can't use the name 'state' here because it's reserved for overriding Redux state.
|
||||
Omit<AnnualReportAnnouncementProps, 'state'> & {
|
||||
reportState: AnnualReportAnnouncementProps['state'];
|
||||
},
|
||||
} 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;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => <AnnualReportAnnouncement {...args} />,
|
||||
};
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
reportState: 'generating',
|
||||
},
|
||||
render: Default.render,
|
||||
};
|
||||
|
||||
export const WithData: Story = {
|
||||
args: {
|
||||
hasData: true,
|
||||
reportState: 'available',
|
||||
},
|
||||
render: Default.render,
|
||||
};
|
||||
|
||||
@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export const AnnualReportAnnouncement: React.FC<{
|
||||
export interface AnnualReportAnnouncementProps {
|
||||
year: string;
|
||||
hasData: boolean;
|
||||
isLoading: boolean;
|
||||
state: Exclude<ApiAnnualReportState, 'ineligible'>;
|
||||
onRequestBuild: () => void;
|
||||
onOpen: () => void;
|
||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
||||
onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then.
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
values={{ year }}
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.description'
|
||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||
/>
|
||||
</p>
|
||||
{hasData ? (
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
values={{ year }}
|
||||
tagName='h2'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.description'
|
||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||
tagName='p'
|
||||
/>
|
||||
{state === 'available' ? (
|
||||
<Button onClick={onOpen}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_view'
|
||||
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<Button loading={isLoading} onClick={onRequestBuild}>
|
||||
<Button loading={state === 'generating'} onClick={onRequestBuild}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_build'
|
||||
defaultMessage='Build my Wrapstodon'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{state === 'eligible' && (
|
||||
<Button onClick={onDismiss} plain className={styles.closeButton}>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.action_dismiss'
|
||||
defaultMessage='No thanks'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
|
||||
var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
@@ -26,4 +28,15 @@
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
:global(.modal-root__modal) & {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import replier from '@/images/archetypes/replier.png';
|
||||
import space_elements from '@/images/archetypes/space_elements.png';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import type {
|
||||
AnnualReport,
|
||||
Archetype as ArchetypeData,
|
||||
} from '@/mastodon/models/annual_report';
|
||||
import { wrapstodonSettings } from '@/mastodon/settings';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
import { ShareButton } from './share_button';
|
||||
@@ -117,9 +119,16 @@ export const Archetype: React.FC<{
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isSelfView = context === 'modal';
|
||||
|
||||
const [isRevealed, setIsRevealed] = useState(!isSelfView);
|
||||
const [isRevealed, setIsRevealed] = useState(
|
||||
() =>
|
||||
!isSelfView ||
|
||||
(me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true),
|
||||
);
|
||||
const reveal = useCallback(() => {
|
||||
setIsRevealed(true);
|
||||
if (me) {
|
||||
wrapstodonSettings.set(me, { archetypeRevealed: true });
|
||||
}
|
||||
wrapperRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
@@ -128,7 +137,8 @@ export const Archetype: React.FC<{
|
||||
? archetypeSelfDescriptions
|
||||
: 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 (
|
||||
<div
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
@typescript-eslint/no-unsafe-member-access,
|
||||
@typescript-eslint/no-unsafe-call */
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { InterceptStatusClicks } from 'mastodon/components/status/intercept_status_clicks';
|
||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||
import type { TopStatuses } from 'mastodon/models/annual_report';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
@@ -29,6 +33,24 @@ export const HighlightedPost: React.FC<{
|
||||
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) {
|
||||
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
|
||||
}
|
||||
@@ -72,7 +94,9 @@ export const HighlightedPost: React.FC<{
|
||||
{context === 'modal' && <p>{label}</p>}
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager showActions={false} id={statusId} />
|
||||
<InterceptStatusClicks onPreventedClick={handleClick}>
|
||||
<StatusQuoteManager showActions={false} id={statusId} />
|
||||
</InterceptStatusClicks>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,18 +10,17 @@ $mobile-breakpoint: 540px;
|
||||
}
|
||||
|
||||
.modalWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
padding-inline: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loading-indicator .circular-progress {
|
||||
@@ -50,37 +49,51 @@ $mobile-breakpoint: 540px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
--gradient-strength: 0.4;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
max-width: 600px;
|
||||
padding: 24px;
|
||||
padding-top: 40px;
|
||||
contain: layout;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
pointer-events: all;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
background:
|
||||
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
||||
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
||||
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
||||
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%),
|
||||
radial-gradient(at 80% 91%, #16dae499 0, transparent 50%)
|
||||
radial-gradient(
|
||||
at 10% 27%,
|
||||
rgba(83, 12, 154, var(--gradient-strength)) 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);
|
||||
border-radius: 40px;
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
padding-inline: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: inherit;
|
||||
border-radius: inherit;
|
||||
filter: blur(20px);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +105,7 @@ $mobile-breakpoint: 540px;
|
||||
font-family: silkscreen-wrapstodon, monospace;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
padding-inline: 40px; // Prevent overlap with close button
|
||||
|
||||
@media (width < $mobile-breakpoint) {
|
||||
@@ -116,7 +129,7 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: rgb(from var(--color-bg-primary) r g b / 60%);
|
||||
box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
|
||||
@@ -150,7 +163,6 @@ $mobile-breakpoint: 540px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
@@ -162,8 +174,12 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
color: #c2c8ff;
|
||||
color: var(--color-text-brand-soft);
|
||||
font-weight: 500;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.statLarge {
|
||||
@@ -185,7 +201,7 @@ $mobile-breakpoint: 540px;
|
||||
|
||||
.mostBoostedPost {
|
||||
padding: 0;
|
||||
padding-top: 8px;
|
||||
padding-top: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -260,7 +276,7 @@ $mobile-breakpoint: 540px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
max-width: 460px;
|
||||
@@ -316,3 +332,20 @@ $mobile-breakpoint: 540px;
|
||||
left: 0;
|
||||
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 type { FC } from 'react';
|
||||
|
||||
import { defineMessage, useIntl } from 'react-intl';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -11,7 +11,11 @@ import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
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 { Archetype } from './archetype';
|
||||
@@ -23,10 +27,18 @@ import { NewPosts } from './new_posts';
|
||||
|
||||
const moduleClassNames = classNames.bind(styles);
|
||||
|
||||
export const shareMessage = defineMessage({
|
||||
id: 'annual_report.summary.share_message',
|
||||
defaultMessage: 'I got the {archetype} archetype!',
|
||||
});
|
||||
const accountSelector = createAppSelector(
|
||||
[(state) => state.accounts, (state) => state.annualReport.report],
|
||||
(accounts, report) => {
|
||||
if (me) {
|
||||
return accounts.get(me);
|
||||
}
|
||||
if (report?.schema_version === 2) {
|
||||
return accounts.get(report.account_id);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
context = 'standalone',
|
||||
@@ -34,15 +46,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const report = useAppSelector((state) => state.annualReport.report);
|
||||
const account = useAppSelector((state) => {
|
||||
if (me) {
|
||||
return state.accounts.get(me);
|
||||
}
|
||||
if (report?.schema_version === 2) {
|
||||
return state.accounts.get(report.account_id);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const account = useAppSelector(accountSelector);
|
||||
|
||||
const close = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
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 { AnnualReportAnnouncement } from './announcement';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
const AnnualReportModal: React.FC<{
|
||||
@@ -12,15 +21,66 @@ const AnnualReportModal: React.FC<{
|
||||
onChangeBackgroundColor('var(--color-bg-media-base)');
|
||||
}, [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 (
|
||||
// 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
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
<AnnualReport context='modal' />
|
||||
{!showAnnouncement ? (
|
||||
<AnnualReport context='modal' />
|
||||
) : (
|
||||
<AnnualReportAnnouncement
|
||||
year={year.toString()}
|
||||
state={state}
|
||||
onDismiss={handleClose}
|
||||
onRequestBuild={handleBuildRequest}
|
||||
/>
|
||||
)}
|
||||
</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 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 { closeModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { shareMessage } from '.';
|
||||
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 }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleShareClick = useCallback(() => {
|
||||
// Generate the share message.
|
||||
const archetypeName = intl.formatMessage(
|
||||
archetypeNames[report.data.archetype],
|
||||
);
|
||||
const shareLines = [
|
||||
intl.formatMessage(shareMessage, {
|
||||
intl.formatMessage(messages.share_message, {
|
||||
archetype: archetypeName,
|
||||
}),
|
||||
];
|
||||
@@ -37,5 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
|
||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||
}, [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,20 +0,0 @@
|
||||
.wrapper {
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
padding: 40px 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconLogo } from '@/mastodon/components/logo';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
|
||||
import { AnnualReport } from './index';
|
||||
import classes from './shared_page.module.css';
|
||||
import classes from './shared_page.module.scss';
|
||||
|
||||
export const WrapstodonSharedPage: FC = () => {
|
||||
return (
|
||||
@@ -16,8 +17,27 @@ export const WrapstodonSharedPage: FC = () => {
|
||||
<FormattedMessage
|
||||
id='annual_report.shared_page.footer'
|
||||
defaultMessage='Generated with {heart} by the Mastodon team'
|
||||
values={{ heart: '♥' }}
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { useDismissible } from '@/mastodon/hooks/useDismissible';
|
||||
import {
|
||||
generateReport,
|
||||
selectWrapstodonYear,
|
||||
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
|
||||
void dispatch(generateReport());
|
||||
}, [dispatch]);
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissible(
|
||||
`annual_report_announcement_${year}`,
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
|
||||
}, [dispatch]);
|
||||
dismiss();
|
||||
}, [dismiss, dispatch]);
|
||||
|
||||
if (!year || !state || state === 'ineligible') {
|
||||
if (!year || wasDismissed || !state || state === 'ineligible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnualReportAnnouncement
|
||||
year={year.toString()}
|
||||
hasData={state === 'available'}
|
||||
isLoading={state === 'generating'}
|
||||
state={state}
|
||||
onRequestBuild={handleBuildRequest}
|
||||
onOpen={handleOpen}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,8 +41,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
||||
};
|
||||
|
||||
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
const volume = playerSettings.get('volume') ?? 0.5;
|
||||
const muted = playerSettings.get('muted') ?? false;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted;
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CriticalUpdateBanner = () => (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<h1>
|
||||
import { criticalUpdatesPending } from '@/mastodon/initial_state';
|
||||
|
||||
export const CriticalUpdateBanner: FC = () => {
|
||||
if (!criticalUpdatesPending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.title'
|
||||
defaultMessage='Critical security update available!'
|
||||
tagName='h1'
|
||||
/>
|
||||
</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
||||
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
|
||||
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 { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { Announcements } from './components/announcements';
|
||||
import { AnnualReportTimeline } from '../annual_report/timeline';
|
||||
|
||||
const messages = defineMessages({
|
||||
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 pinned = !!columnId;
|
||||
const { signedIn } = this.props.identity;
|
||||
const banners = [];
|
||||
const banners = [
|
||||
<CriticalUpdateBanner key='critical-update-banner' />,
|
||||
<AnnualReportTimeline key='annual-report' />
|
||||
];
|
||||
|
||||
let announcementsButton;
|
||||
|
||||
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (criticalUpdatesPending) {
|
||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
|
||||
@@ -46,6 +46,8 @@ import { canViewFeed } from 'mastodon/permissions';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||
|
||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||
import { ListPanel } from './components/list_panel';
|
||||
@@ -294,6 +296,8 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<AnnualReportNavItem />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
@@ -139,8 +139,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
||||
};
|
||||
|
||||
const restoreVolume = (video: HTMLVideoElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
const volume = playerSettings.get('volume') ?? 0.5;
|
||||
const muted = playerSettings.get('muted') ?? false;
|
||||
|
||||
video.volume = volume;
|
||||
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,10 +114,14 @@
|
||||
"alt_text_modal.done": "Done",
|
||||
"announcement.announcement": "Announcement",
|
||||
"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.description": "Discover more about your engagement on Mastodon over the past year.",
|
||||
"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_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
|
||||
"annual_report.summary.archetype.booster.name": "The Archer",
|
||||
@@ -139,6 +143,7 @@
|
||||
"annual_report.summary.archetype.title_public": "{name}'s archetype",
|
||||
"annual_report.summary.archetype.title_self": "Your archetype",
|
||||
"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.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}}.",
|
||||
@@ -151,7 +156,9 @@
|
||||
"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.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_on_mastodon": "Share on Mastodon",
|
||||
"attachments_list.unprocessed": "(unprocessed)",
|
||||
"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.",
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
importFetchedAccounts,
|
||||
importFetchedStatuses,
|
||||
} from '@/mastodon/actions/importer';
|
||||
import { insertIntoTimeline } from '@/mastodon/actions/timelines';
|
||||
import { timelineDelete } from '@/mastodon/actions/timelines_typed';
|
||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||
import {
|
||||
apiGetAnnualReport,
|
||||
@@ -21,8 +19,6 @@ import {
|
||||
createDataLoadingThunk,
|
||||
} from '../../store/typed_functions';
|
||||
|
||||
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
|
||||
|
||||
interface AnnualReportState {
|
||||
state?: ApiAnnualReportState;
|
||||
report?: AnnualReport;
|
||||
@@ -64,37 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
|
||||
// This kicks everything off, and is called after fetching the server info.
|
||||
export const checkAnnualReport = createAppThunk(
|
||||
`${annualReportSlice.name}/checkAnnualReport`,
|
||||
async (_arg: unknown, { dispatch, getState }) => {
|
||||
(_arg: unknown, { dispatch, getState }) => {
|
||||
const year = selectWrapstodonYear(getState());
|
||||
if (!year) {
|
||||
return;
|
||||
}
|
||||
const state = await dispatch(fetchReportState());
|
||||
if (
|
||||
state.meta.requestStatus === 'fulfilled' &&
|
||||
state.payload !== 'ineligible'
|
||||
) {
|
||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const reinsertAnnualReport = createAppThunk(
|
||||
`${annualReportSlice.name}/reinsertAnnualReport`,
|
||||
(_arg: unknown, { dispatch, getState }) => {
|
||||
dispatch(
|
||||
timelineDelete({
|
||||
statusId: TIMELINE_WRAPSTODON,
|
||||
accountId: '',
|
||||
references: [],
|
||||
reblogOf: null,
|
||||
}),
|
||||
);
|
||||
const { state } = getState().annualReport;
|
||||
if (!state || state === 'ineligible') {
|
||||
return;
|
||||
}
|
||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
||||
void dispatch(fetchReportState());
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof 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;
|
||||
|
||||
@@ -5,14 +5,14 @@ class AnnualReport::TopStatuses < AnnualReport::Source
|
||||
{
|
||||
top_statuses: {
|
||||
by_reblogs: status_identifier(most_reblogged_status),
|
||||
by_favourites: status_identifier(most_favourited_status),
|
||||
by_replies: status_identifier(most_replied_status),
|
||||
by_favourites: nil,
|
||||
by_replies: nil,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def eligible?
|
||||
report_statuses.public_visibility.exists?
|
||||
report_statuses.distributable_visibility.exists?
|
||||
end
|
||||
|
||||
private
|
||||
@@ -43,7 +43,7 @@ class AnnualReport::TopStatuses < AnnualReport::Source
|
||||
|
||||
def base_scope
|
||||
report_statuses
|
||||
.public_visibility
|
||||
.distributable_visibility
|
||||
.joins(:status_stat)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -451,6 +451,10 @@ class Account < ApplicationRecord
|
||||
save!
|
||||
end
|
||||
|
||||
def featureable?
|
||||
local? && discoverable?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_contents
|
||||
|
||||
@@ -32,6 +32,8 @@ class CollectionItem < ApplicationRecord
|
||||
validates :account, presence: true, if: :accepted?
|
||||
validates :object_uri, presence: true, if: -> { account.nil? }
|
||||
|
||||
before_validation :set_position, on: :create
|
||||
|
||||
scope :ordered, -> { order(position: :asc) }
|
||||
scope :with_accounts, -> { includes(account: [:account_stat, :user]) }
|
||||
scope :not_blocked_by, ->(account) { where.not(accounts: { id: account.blocking }) }
|
||||
@@ -39,4 +41,12 @@ class CollectionItem < ApplicationRecord
|
||||
def local_item_with_remote_account?
|
||||
local? && account&.remote?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_position
|
||||
return if position_changed?
|
||||
|
||||
self.position = self.class.where(collection_id:).maximum(:position).to_i + 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -64,4 +64,8 @@ class AccountPolicy < ApplicationPolicy
|
||||
def review?
|
||||
role.can?(:manage_taxonomies)
|
||||
end
|
||||
|
||||
def feature?
|
||||
record.featureable? && !current_account.blocking?(record) && !record.blocking?(current_account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
class REST::CollectionItemSerializer < ActiveModel::Serializer
|
||||
delegate :accepted?, to: :object
|
||||
|
||||
attributes :position, :state
|
||||
attributes :id, :position, :state
|
||||
|
||||
belongs_to :account, serializer: REST::AccountSerializer, if: :accepted?
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
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
|
||||
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'
|
||||
@@ -9,7 +9,9 @@
|
||||
= 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'
|
||||
|
||||
- content_for :html_classes, 'theme-dark'
|
||||
|
||||
@@ -31,7 +31,7 @@ el:
|
||||
glitch_guide_link_text: Και ομοίως για το glitch-soc!
|
||||
auth:
|
||||
captcha_confirmation:
|
||||
hint_html: Απλώς ένα βήμα ακόμη! Για να επιβεβαιώσεις τον λογαριασμό σου, αυτός ο διακομιστής απαιτεί να λύσεις ένα CAPTCHA. Μπορείς να <a href="/about/more">επικοινωνήσεις με τον διαχειριστή του διακομιστή</a> αν έχεις ερωτήσεις ή χρειάζεσαι βοήθεια με την επιβεβαίωση του λογαριασμού σου.
|
||||
hint_html: Απλώς ένα βήμα ακόμα! Για να επιβεβαιώσεις τον λογαριασμό σου, αυτός ο διακομιστής απαιτεί να λύσεις ένα CAPTCHA. Μπορείς να <a href="/about/more">επικοινωνήσεις με τον διαχειριστή του διακομιστή</a> αν έχεις ερωτήσεις ή χρειάζεσαι βοήθεια με την επιβεβαίωση του λογαριασμού σου.
|
||||
title: Επαλήθευση χρήστη
|
||||
generic:
|
||||
use_this: Χρησιμοποιήστε αυτό
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
---
|
||||
nn:
|
||||
admin:
|
||||
custom_emojis:
|
||||
batch_copy_error: 'En feil oppsto ved kopiering av noen av de valgte emojiene: %{message}'
|
||||
batch_error: 'En feil oppsto: %{message}'
|
||||
settings:
|
||||
flavour_and_skin:
|
||||
title: Variant og tema
|
||||
hide_followers_count:
|
||||
desc_html: Ikke vis følgerantallet på brukerprofiler
|
||||
title: Skjul følgerantall
|
||||
other:
|
||||
preamble: Diverse innstillinger for glitch-soc som ikke passer i andre kategorier.
|
||||
title: Andre
|
||||
outgoing_spoilers:
|
||||
title: Innholdsvarsel for utgående innlegg
|
||||
show_reblogs_in_public_timelines:
|
||||
desc_html: Vis offentlige fremhevinger av offentlige innlegg i lokale og offentlige tidslinjer.
|
||||
title: Vis fremhevinger i offentlige tidslinjer
|
||||
show_replies_in_public_timelines:
|
||||
title: Vis svar i offentlige tidslinjer
|
||||
trending_status_cw:
|
||||
title: Tillat innlegg med innholdsvarsler å trende
|
||||
appearance:
|
||||
localization:
|
||||
glitch_guide_link: https://crowdin.com/project/glitch-soc
|
||||
glitch_guide_link_text: Det gjelder også glitch-soc!
|
||||
generic:
|
||||
use_this: Bruk dette
|
||||
settings:
|
||||
flavours: Varianter
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
---
|
||||
'no':
|
||||
admin:
|
||||
custom_emojis:
|
||||
batch_copy_error: 'En feil oppsto ved kopiering av noen av de valgte emojiene: %{message}'
|
||||
batch_error: 'En feil oppsto: %{message}'
|
||||
settings:
|
||||
flavour_and_skin:
|
||||
title: Variant og tema
|
||||
hide_followers_count:
|
||||
desc_html: Ikke vis følgerantallet på brukerprofiler
|
||||
title: Skjul følgerantall
|
||||
other:
|
||||
preamble: Diverse innstillinger for glitch-soc som ikke passer i andre kategorier.
|
||||
title: Andre
|
||||
outgoing_spoilers:
|
||||
title: Innholdsvarsel for utgående innlegg
|
||||
show_reblogs_in_public_timelines:
|
||||
desc_html: Vis offentlige fremhevinger av offentlige innlegg i lokale og offentlige tidslinjer.
|
||||
title: Vis fremhevinger i offentlige tidslinjer
|
||||
show_replies_in_public_timelines:
|
||||
title: Vis svar i offentlige tidslinjer
|
||||
trending_status_cw:
|
||||
title: Tillat innlegg med innholdsvarsler å trende
|
||||
appearance:
|
||||
localization:
|
||||
glitch_guide_link: https://crowdin.com/project/glitch-soc
|
||||
glitch_guide_link_text: Det gjelder også glitch-soc!
|
||||
generic:
|
||||
use_this: Bruk dette
|
||||
settings:
|
||||
flavours: Varianter
|
||||
|
||||
@@ -8,7 +8,7 @@ el:
|
||||
setting_default_content_type_markdown: Κατά τη γραφή των τουτς, υποθέτει ότι χρησιμοποιείται το Markdown για μορφοποίηση πλούσιου κειμένου, εκτός αν ορίζεται διαφορετικά
|
||||
setting_default_content_type_plain: Κατά τη γραφή των τουτς, υποθέτει ότι είναι απλό κείμενο χωρίς ειδική μορφοποίηση, εκτός αν ορίζεται διαφορετικά (προεπιλεγμένη συμπεριφορά Mastodon)
|
||||
setting_default_language: Η γλώσσα των τουτ σας μπορεί να εντοπιστεί αυτόματα, αλλά δεν είναι πάντα ακριβής
|
||||
setting_show_followers_count: Εμφάνιση του αριθμού ακολούθων σας στο προφίλ σας. Αν αποκρύψετε τον αριθμό των ακολούθων σας, θα είναι κρυμμένος ακόμη και από εσάς, και μερικές εφαρμογές μπορεί να εμφανίσουν έναν αρνητικό αριθμό ακολούθων.
|
||||
setting_show_followers_count: Εμφάνιση του αριθμού ακολούθων σας στο προφίλ σας. Αν αποκρύψετε τον αριθμό των ακολούθων σας, θα είναι κρυμμένος ακόμα και από εσάς, και μερικές εφαρμογές μπορεί να εμφανίσουν έναν αρνητικό αριθμό ακολούθων.
|
||||
labels:
|
||||
defaults:
|
||||
setting_default_content_type: Προεπιλεγμένη μορφή για τουτς
|
||||
|
||||
@@ -8,7 +8,7 @@ ko:
|
||||
setting_default_content_type_markdown: 게시물을 작성할 때, 형식을 지정하지 않았다면, 마크다운이라고 가정합니다
|
||||
setting_default_content_type_plain: 게시물을 작성할 때, 형식을 지정하지 않았다면, 일반적인 텍스트라고 가정합니다. (마스토돈의 기본 동작)
|
||||
setting_default_language: 작성하는 게시물의 언어는 자동으로 설정될 수 있습니다, 하지만 언제나 정확하지는 않습니다
|
||||
setting_show_followers_count: 팔로워 카운트를 프로필에서 숨깁니다. 팔로워 수를 숨기면 나에게도 보이지 않으며 몇몇 앱에서는 팔로워 수가 음수로 표시될 수 있습니다.
|
||||
setting_show_followers_count: 팔로워 카운트를 프로필에 표시합니다. 팔로워 수를 숨기면 나에게도 보이지 않으며 몇몇 앱에서는 팔로워 수가 음수로 표시될 수 있습니다.
|
||||
setting_skin: 선택한 마스토돈 풍미의 스킨을 바꿉니다
|
||||
labels:
|
||||
defaults:
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
---
|
||||
nn:
|
||||
simple_form:
|
||||
glitch_only: glitch-soc
|
||||
hints:
|
||||
defaults:
|
||||
setting_default_language: Språket i innleggene deres kan oppdages automatisk, men det er ikke alltid nøyaktig
|
||||
labels:
|
||||
defaults:
|
||||
setting_default_content_type: Standardformat for innlegg
|
||||
setting_default_content_type_html: HTML
|
||||
setting_default_content_type_markdown: Markdown
|
||||
setting_default_content_type_plain: Ren tekst
|
||||
setting_show_followers_count: Vis følgerantallet deres
|
||||
setting_skin: Tema
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
---
|
||||
'no':
|
||||
simple_form:
|
||||
glitch_only: glitch-soc
|
||||
hints:
|
||||
defaults:
|
||||
setting_default_language: Språket i innleggene deres kan oppdages automatisk, men det er ikke alltid nøyaktig
|
||||
labels:
|
||||
defaults:
|
||||
setting_default_content_type: Standardformat for innlegg
|
||||
setting_default_content_type_html: HTML
|
||||
setting_default_content_type_markdown: Markdown
|
||||
setting_default_content_type_plain: Ren tekst
|
||||
setting_show_followers_count: Vis følgerantallet deres
|
||||
setting_skin: Tema
|
||||
|
||||
@@ -7,6 +7,8 @@ en:
|
||||
hosted_on: Mastodon hosted on %{domain}
|
||||
title: About
|
||||
accounts:
|
||||
errors:
|
||||
cannot_be_added_to_collections: This account cannot be added to collections.
|
||||
followers:
|
||||
one: Follower
|
||||
other: Followers
|
||||
|
||||
@@ -12,7 +12,9 @@ namespace :api, format: false do
|
||||
|
||||
resources :async_refreshes, only: :show
|
||||
|
||||
resources :collections, only: [:show, :create, :update, :destroy]
|
||||
resources :collections, only: [:show, :create, :update, :destroy] do
|
||||
resources :items, only: [:create], controller: 'collection_items'
|
||||
end
|
||||
end
|
||||
|
||||
# JSON / REST API
|
||||
|
||||
@@ -40,8 +40,8 @@ RSpec.describe AnnualReport::TopStatuses do
|
||||
.to include(
|
||||
top_statuses: include(
|
||||
by_reblogs: reblogged_status.id.to_s,
|
||||
by_favourites: favourited_status.id.to_s,
|
||||
by_replies: replied_status.id.to_s
|
||||
by_favourites: nil,
|
||||
by_replies: nil
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -781,4 +781,37 @@ RSpec.describe Account do
|
||||
expect(subject.reload.followers_count).to eq 15
|
||||
end
|
||||
end
|
||||
|
||||
describe '#featureable?' do
|
||||
subject { Fabricate.build(:account, domain: (local ? nil : 'example.com'), discoverable:) }
|
||||
|
||||
context 'when account is local' do
|
||||
let(:local) { true }
|
||||
|
||||
context 'when account is discoverable' do
|
||||
let(:discoverable) { true }
|
||||
|
||||
it 'returns `true`' do
|
||||
expect(subject.featureable?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is not discoverable' do
|
||||
let(:discoverable) { false }
|
||||
|
||||
it 'returns `false`' do
|
||||
expect(subject.featureable?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is remote' do
|
||||
let(:local) { false }
|
||||
let(:discoverable) { true }
|
||||
|
||||
it 'returns `false`' do
|
||||
expect(subject.featureable?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,4 +38,23 @@ RSpec.describe CollectionItem do
|
||||
it { is_expected.to validate_presence_of(:object_uri) }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Creation' do
|
||||
let(:collection) { Fabricate(:collection) }
|
||||
let(:other_collection) { Fabricate(:collection) }
|
||||
let(:account) { Fabricate(:account) }
|
||||
let(:other_account) { Fabricate(:account) }
|
||||
|
||||
it 'automatically sets the `position` if absent' do
|
||||
first_item = collection.collection_items.create(account:)
|
||||
second_item = collection.collection_items.create(account: other_account)
|
||||
unrelated_item = other_collection.collection_items.create(account:)
|
||||
custom_item = other_collection.collection_items.create(account: other_account, position: 7)
|
||||
|
||||
expect(first_item.position).to eq 1
|
||||
expect(second_item.position).to eq 2
|
||||
expect(unrelated_item.position).to eq 1
|
||||
expect(custom_item.position).to eq 7
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -156,4 +156,36 @@ RSpec.describe AccountPolicy do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
permissions :feature? do
|
||||
context 'when account is featureable?' do
|
||||
it 'permits' do
|
||||
expect(subject).to permit(alice, john)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is not featureable' do
|
||||
before { allow(alice).to receive(:featureable?).and_return(false) }
|
||||
|
||||
it 'denies' do
|
||||
expect(subject).to_not permit(john, alice)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is blocked' do
|
||||
before { alice.block!(john) }
|
||||
|
||||
it 'denies' do
|
||||
expect(subject).to_not permit(alice, john)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account is blocking' do
|
||||
before { john.block!(alice) }
|
||||
|
||||
it 'denies' do
|
||||
expect(subject).to_not permit(alice, john)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -75,6 +75,33 @@ RSpec.describe 'Credentials' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with client credentials' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read admin:write') }
|
||||
let(:token) { Fabricate(:client_credentials_token, application: application, scopes: 'read admin:write') }
|
||||
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
|
||||
|
||||
it 'returns http success and returns app information' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to start_with('application/json')
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
a_hash_including(
|
||||
id: token.application.id.to_s,
|
||||
name: token.application.name,
|
||||
website: token.application.website,
|
||||
scopes: token.application.scopes.map(&:to_s),
|
||||
redirect_uris: token.application.redirect_uris,
|
||||
# Deprecated properties as of 4.3:
|
||||
redirect_uri: token.application.redirect_uri.split.first,
|
||||
vapid_key: Rails.configuration.x.vapid.public_key
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without an oauth token' do
|
||||
let(:headers) { {} }
|
||||
|
||||
|
||||
55
spec/requests/api/v1_alpha/collection_items_spec.rb
Normal file
55
spec/requests/api/v1_alpha/collection_items_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
|
||||
include_context 'with API authentication', oauth_scopes: 'read:collections write:collections'
|
||||
|
||||
describe 'POST /api/v1_alpha/collections/:collection_id/items' do
|
||||
subject do
|
||||
post "/api/v1_alpha/collections/#{collection.id}/items", headers: headers, params: params
|
||||
end
|
||||
|
||||
let(:collection) { Fabricate(:collection, account: user.account) }
|
||||
let(:params) { {} }
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'read'
|
||||
|
||||
context 'when user is owner of the collection' do
|
||||
context 'with valid params' do
|
||||
let(:other_account) { Fabricate(:account) }
|
||||
let(:params) { { account_id: other_account.id } }
|
||||
|
||||
it 'creates a collection item and returns http success' do
|
||||
expect do
|
||||
subject
|
||||
end.to change(collection.collection_items, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid params' do
|
||||
it 'returns http unprocessable content' do
|
||||
expect do
|
||||
subject
|
||||
end.to_not change(CollectionItem, :count)
|
||||
|
||||
expect(response).to have_http_status(422)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not the owner of the collection' do
|
||||
let(:collection) { Fabricate(:collection) }
|
||||
let(:other_account) { Fabricate(:account) }
|
||||
let(:params) { { account_id: other_account.id } }
|
||||
|
||||
it 'returns http forbidden' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ RSpec.describe REST::CollectionItemSerializer do
|
||||
|
||||
let(:collection_item) do
|
||||
Fabricate(:collection_item,
|
||||
id: 2342,
|
||||
state:,
|
||||
position: 4)
|
||||
end
|
||||
@@ -17,6 +18,7 @@ RSpec.describe REST::CollectionItemSerializer do
|
||||
it 'includes the relevant attributes including the account' do
|
||||
expect(subject)
|
||||
.to include(
|
||||
'id' => '2342',
|
||||
'account' => an_instance_of(Hash),
|
||||
'state' => 'accepted',
|
||||
'position' => 4
|
||||
|
||||
35
spec/services/add_account_to_collection_service_spec.rb
Normal file
35
spec/services/add_account_to_collection_service_spec.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AddAccountToCollectionService do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:collection) { Fabricate.create(:collection) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when given a featurable account' do
|
||||
let(:account) { Fabricate(:account) }
|
||||
|
||||
it 'creates a new CollectionItem in the `accepted` state' do
|
||||
expect do
|
||||
subject.call(collection, account)
|
||||
end.to change(collection.collection_items, :count).by(1)
|
||||
|
||||
new_item = collection.collection_items.last
|
||||
expect(new_item.state).to eq 'accepted'
|
||||
expect(new_item.account).to eq account
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given an account that is not featureable' do
|
||||
let(:account) { Fabricate(:account, discoverable: false) }
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
subject.call(collection, account)
|
||||
end.to raise_error(Mastodon::NotPermittedError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ module.exports = {
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
'files': ['app/javascript/styles/entrypoints/mailer.scss'],
|
||||
files: ['app/javascript/styles/entrypoints/mailer.scss'],
|
||||
rules: {
|
||||
'property-no-unknown': [
|
||||
true,
|
||||
@@ -44,5 +44,14 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['app/javascript/**/*.module.scss'],
|
||||
rules: {
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{ ignorePseudoClasses: ['global'] },
|
||||
]
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user