Compare commits

...

44 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 18:07:34 +01:00
diondiondion
c06eb371e6 Add Wrapstodon footer links (#37207) 2025-12-11 17:06:26 +00:00
Claire
53617cef5a Merge commit 'd730f6b0c5cfb18894d1a9e34d0aa2556dda3c62' into glitch-soc/merge-upstream 2025-12-11 18:05:28 +01:00
Emelia Smith
d730f6b0c5 Add spec for client_credentials being used with /api/v1/apps/verify_credentials (#37195) 2025-12-11 16:40:22 +00:00
Claire
addeb28292 Change wrapstodon 2025 to allow unlisted posts in top statuses (#37206) 2025-12-11 16:35:35 +00:00
Claire
5e3387539e Add image to Wrapstodon OpenGraph banner (#37205) 2025-12-11 16:22:48 +00:00
diondiondion
4323963053 Fix Wrapstodon modal scrolling not working on iOS (#37203) 2025-12-11 14:25:28 +00:00
diondiondion
5651900b89 Wrapstodon design QA tweaks (#37201) 2025-12-11 11:40:53 +00:00
renovate[bot]
d1b996b7e3 Update dependency omniauth-rails_csrf_protection to v2.0.1 (#37199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 11:12:07 +00:00
renovate[bot]
fed26a41fa Update dependency jsdom to v27.3.0 (#37165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:33:47 +00:00
Claire
37d309bcaf Fix Wrapstodon font loading by disabling inlining of fonts in Vite (#37198) 2025-12-11 10:33:15 +00:00
renovate[bot]
d25f672c50 Update dependency active_model_serializers to v0.10.16 (#37167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:52 +00:00
renovate[bot]
15c9088761 Update dependency vite to v7.2.7 (#37156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:38 +00:00
renovate[bot]
da1505a495 Update dependency @vitejs/plugin-react to v5.1.2 (#37155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:30 +00:00
renovate[bot]
d1f690f50c Update dependency stoplight to v5.7.0 (#37151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:25 +00:00
Claire
da2b75bdcd Change build-releases workflow to tag images latest based on latest stable-x.y branch (#37179)
Co-authored-by: emilweth <7402764+emilweth@users.noreply.github.com>
2025-12-10 17:01:25 +00:00
David Roetzel
adf8a3601d Add service to add item to a collection (#37192) 2025-12-10 16:59:21 +00:00
102 changed files with 1905 additions and 633 deletions

View File

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

View File

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

View File

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

View File

@@ -72,10 +72,13 @@ module SignatureVerification
rescue Mastodon::SignatureVerificationError => e
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

View File

@@ -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));

View File

@@ -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));

View File

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

View File

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

View File

@@ -6,13 +6,13 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import { 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;

View File

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

View File

@@ -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,
};

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

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

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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 }));

View File

@@ -1,8 +1,17 @@
import { useEffect } from 'react';
import type { MouseEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { 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>
);
};

View File

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

View File

@@ -1,27 +1,52 @@
import { useCallback } from 'react';
import 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>
);
};

View File

@@ -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;
}

View File

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

View File

@@ -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>
);

View File

@@ -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}
/>
);
};

View File

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

View File

@@ -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>
);
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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());
},
);

View File

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

View File

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

View File

@@ -14,3 +14,9 @@
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -144,7 +144,7 @@ export const hydrateSearch = createAppAsyncThunk(
'search/hydrate',
(_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));

View File

@@ -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));

View File

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

View File

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

View File

@@ -6,13 +6,13 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import { 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;

View File

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

View File

@@ -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,
};

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

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

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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 }));

View File

@@ -1,8 +1,17 @@
import { useEffect } from 'react';
import type { MouseEventHandler } from 'react';
import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { 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>
);
};

View File

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

View File

@@ -1,27 +1,52 @@
import { useCallback } from 'react';
import 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>
);
};

View File

@@ -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;
}

View File

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

View File

@@ -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>
);

View File

@@ -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}
/>
);
};

View File

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

View File

@@ -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>
);
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,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.",

View File

@@ -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());
},
);

View File

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

View File

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

View File

@@ -14,3 +14,9 @@
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: Χρησιμοποιήστε αυτό

View File

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

View File

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

View File

@@ -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: Προεπιλεγμένη μορφή για τουτς

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) { {} }

View 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

View File

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

View 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

View File

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