-
-
+import { criticalUpdatesPending } from '@/mastodon/initial_state';
+
+export const CriticalUpdateBanner: FC = () => {
+ if (!criticalUpdatesPending) {
+ return null;
+ }
+ return (
+
+
-
-
- {' '}
-
+
-
-
+ id='home.pending_critical_update.body'
+ defaultMessage='Please update your Mastodon server as soon as possible!'
+ />{' '}
+
+
+
+
+
-
-);
+ );
+};
diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx
index 8c5555fd49..893e2c08ca 100644
--- a/app/javascript/mastodon/features/home_timeline/index.jsx
+++ b/app/javascript/mastodon/features/home_timeline/index.jsx
@@ -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 = [
+
,
+
+ ];
let announcementsButton;
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
);
}
- if (criticalUpdatesPending) {
- banners.push(
);
- }
-
return (
= ({
+
+
diff --git a/app/javascript/mastodon/features/video/index.tsx b/app/javascript/mastodon/features/video/index.tsx
index aa03e3d2e9..6721b5c96a 100644
--- a/app/javascript/mastodon/features/video/index.tsx
+++ b/app/javascript/mastodon/features/video/index.tsx
@@ -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;
diff --git a/app/javascript/mastodon/hooks/useDismissible.ts b/app/javascript/mastodon/hooks/useDismissible.ts
new file mode 100644
index 0000000000..95f94d9717
--- /dev/null
+++ b/app/javascript/mastodon/hooks/useDismissible.ts
@@ -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
+ >
+ ).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,
+ };
+}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 0d46b446a7..bafbb3fafd 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -114,9 +114,11 @@
"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",
@@ -141,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}}.",
@@ -153,6 +156,7 @@
"annual_report.summary.new_posts.new_posts": "new posts",
"annual_report.summary.percentile.text": "That puts you in the topof {domain} users.",
"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)",
diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts
index 3ad18f8ec1..e242fdbf9a 100644
--- a/app/javascript/mastodon/reducers/slices/annual_report.ts
+++ b/app/javascript/mastodon/reducers/slices/annual_report.ts
@@ -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());
},
);
diff --git a/app/javascript/mastodon/settings.js b/app/javascript/mastodon/settings.js
deleted file mode 100644
index f4883dc406..0000000000
--- a/app/javascript/mastodon/settings.js
+++ /dev/null
@@ -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');
diff --git a/app/javascript/mastodon/settings.ts b/app/javascript/mastodon/settings.ts
new file mode 100644
index 0000000000..87c67a6e04
--- /dev/null
+++ b/app/javascript/mastodon/settings.ts
@@ -0,0 +1,68 @@
+import type { RecentSearch } from './models/search';
+
+export class Settings> {
+ 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(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(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(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
+>('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<{ volume: number; muted: boolean }>(
+ 'mastodon_player',
+);
+export const wrapstodonSettings = new Settings<
+ Record
+>('wrapstodon');
diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts
index 24b9ee180f..eb45881ee4 100644
--- a/app/javascript/mastodon/utils/types.ts
+++ b/app/javascript/mastodon/utils/types.ts
@@ -14,3 +14,9 @@
export type SomeRequired = T & Required>;
export type SomeOptional = Pick> &
Partial>;
+
+export type OmitValueType = {
+ [K in keyof T as T[K] extends V ? never : K]: T[K];
+};
+
+export type AnyFunction = (...args: never) => unknown;
diff --git a/app/serializers/rest/collection_item_serializer.rb b/app/serializers/rest/collection_item_serializer.rb
index c0acc87bfd..d35a8fdef2 100644
--- a/app/serializers/rest/collection_item_serializer.rb
+++ b/app/serializers/rest/collection_item_serializer.rb
@@ -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
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 3eb97bb740..f0557a5d39 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -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
diff --git a/spec/requests/api/v1_alpha/collection_items_spec.rb b/spec/requests/api/v1_alpha/collection_items_spec.rb
new file mode 100644
index 0000000000..880fd5d47d
--- /dev/null
+++ b/spec/requests/api/v1_alpha/collection_items_spec.rb
@@ -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
diff --git a/spec/serializers/rest/collection_item_serializer_spec.rb b/spec/serializers/rest/collection_item_serializer_spec.rb
index bcb7458c4d..b12553ec03 100644
--- a/spec/serializers/rest/collection_item_serializer_spec.rb
+++ b/spec/serializers/rest/collection_item_serializer_spec.rb
@@ -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
diff --git a/stylelint.config.js b/stylelint.config.js
index 69b874cf22..1352c83567 100644
--- a/stylelint.config.js
+++ b/stylelint.config.js
@@ -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'] },
+ ]
+ }
+ },
],
};