{canvas}
@@ -300,10 +292,10 @@ const Card: React.FC
= ({ card, sensitive }) => {
return (
<>
= ({ card, sensitive }) => {
{description}
- {showAuthor && (
-
- )}
+ {author && }
>
);
};
diff --git a/app/javascript/mastodon/features/video/index.tsx b/app/javascript/mastodon/features/video/index.tsx
index 7a6406885c1..6c6b34a4e11 100644
--- a/app/javascript/mastodon/features/video/index.tsx
+++ b/app/javascript/mastodon/features/video/index.tsx
@@ -7,6 +7,7 @@ import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import { throttle } from 'lodash';
+import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
import FullscreenIcon from '@/material-icons/400-24px/fullscreen.svg?react';
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
@@ -174,15 +175,7 @@ export const Video: React.FC<{
alwaysVisible?: boolean;
visible?: boolean;
onToggleVisibility?: () => void;
- deployPictureInPicture?: (
- type: string,
- mediaProps: {
- src: string;
- muted: boolean;
- volume: number;
- currentTime: number;
- },
- ) => void;
+ deployPictureInPicture?: DeployPictureInPictureCallback;
blurhash?: string;
startPlaying?: boolean;
startTime?: number;
@@ -241,7 +234,7 @@ export const Video: React.FC<{
(c: HTMLVideoElement | null) => {
if (videoRef.current && !videoRef.current.paused && c === null) {
deployPictureInPicture?.('video', {
- src: src,
+ src,
currentTime: videoRef.current.currentTime,
muted: videoRef.current.muted,
volume: videoRef.current.volume,
diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts
index f2523cf3344..ccb6f47621d 100644
--- a/app/javascript/mastodon/models/account.ts
+++ b/app/javascript/mastodon/models/account.ts
@@ -55,6 +55,14 @@ export interface AccountShape extends Required<
moved: string | null;
url: string;
}
+export type AccountShapeFull = Omit<
+ AccountShape,
+ 'emojis' | 'fields' | 'roles'
+> & {
+ emojis: CustomEmoji[];
+ fields: AccountFieldShape[];
+ roles: AccountRoleShape[];
+};
export type Account = RecordOf;
diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts
index dc4138330fc..666d6442664 100644
--- a/app/javascript/mastodon/models/status.ts
+++ b/app/javascript/mastodon/models/status.ts
@@ -2,7 +2,15 @@ import type { RecordOf } from 'immutable';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
-import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
+import type {
+ ApiAudioAttachmentJSON,
+ ApiGifvAttachmentJSON,
+ ApiImageAttachmentJSON,
+ ApiMediaAttachmentJSON,
+ ApiUnknownAttachmentJSON,
+ ApiVideoAttachmentJSON,
+ MediaAttachmentType,
+} from '@/mastodon/api_types/media_attachments';
import type {
ApiQuoteJSON,
ApiQuotePolicyJSON,
@@ -17,6 +25,8 @@ import type {
StatusVisibility,
} from '@/mastodon/api_types/statuses';
+import type { AccountShapeFull } from './account';
+
export type { StatusVisibility } from '@/mastodon/api_types/statuses';
// Temporary until we type it correctly
@@ -75,6 +85,10 @@ export interface StatusShape {
replies_count: number;
visibility: StatusVisibility;
}
+export type ExpandedStatusShape = Omit & {
+ account: AccountShapeFull;
+ reblog?: Omit;
+};
export type CardShape = Omit & {
authors: (Omit & {
@@ -86,14 +100,42 @@ export type Card = RecordOf;
export type MediaAttachment = Immutable.Map;
-export type MediaAttachmentShape = Omit<
- ApiMediaAttachmentJSON,
- 'remote_url'
-> & {
+export type MediaAttachmentShape<
+ TAttachmentJSON extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
+> = Omit & {
remote_url: string | null;
- translation?: string;
+ translation?: {
+ description: string;
+ };
};
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: 'image',
+): attachment is MediaAttachmentShape;
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: 'video',
+): attachment is MediaAttachmentShape;
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: 'gifv',
+): attachment is MediaAttachmentShape;
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: 'audio',
+): attachment is MediaAttachmentShape;
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: 'unknown',
+): attachment is MediaAttachmentShape;
+export function isMediaAttachmentOfType(
+ attachment: MediaAttachmentShape,
+ type: MediaAttachmentType,
+) {
+ return attachment.type === type;
+}
+
export type CollectionAttachment = RecordOf;
export type FilterResult = Omit & {
diff --git a/app/javascript/mastodon/reducers/picture_in_picture.ts b/app/javascript/mastodon/reducers/picture_in_picture.ts
index 10d4f1fae51..b26844c63dc 100644
--- a/app/javascript/mastodon/reducers/picture_in_picture.ts
+++ b/app/javascript/mastodon/reducers/picture_in_picture.ts
@@ -11,10 +11,10 @@ export interface PIPMediaProps {
muted: boolean;
volume: number;
currentTime: number;
- poster: string;
- backgroundColor: string;
- foregroundColor: string;
- accentColor: string;
+ poster?: string;
+ backgroundColor?: string;
+ foregroundColor?: string;
+ accentColor?: string;
}
interface PIPStateWithValue extends Partial {
@@ -34,7 +34,7 @@ const initialState = {
muted: false,
volume: 0,
currentTime: 0,
-};
+} satisfies PIPStateEmpty;
export const pictureInPictureReducer: Reducer = (
state = initialState,
diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts
index bf608fec4e4..74538d366ac 100644
--- a/app/javascript/mastodon/selectors/accounts.ts
+++ b/app/javascript/mastodon/selectors/accounts.ts
@@ -3,7 +3,11 @@ import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
import { me } from 'mastodon/initial_state';
import { accountDefaultValues } from 'mastodon/models/account';
-import type { Account, AccountShape } from 'mastodon/models/account';
+import type {
+ Account,
+ AccountShape,
+ AccountShapeFull,
+} from 'mastodon/models/account';
import type { Relationship } from 'mastodon/models/relationship';
import { createAppSelector } from 'mastodon/store';
import type { RootState } from 'mastodon/store';
@@ -50,6 +54,11 @@ export function makeGetAccount() {
);
}
+export const selectPlainAccount = createAppSelector(
+ [(state, accountId: string) => state.accounts.get(accountId)],
+ (account) => (account ? (account.toJS() as AccountShapeFull) : null),
+);
+
export const getAccountHidden = createAppSelector(
[
(state, id: string) => state.accounts.get(id)?.hidden,
diff --git a/app/javascript/mastodon/selectors/filters.ts b/app/javascript/mastodon/selectors/filters.ts
index f84d01216ad..0520c768c3f 100644
--- a/app/javascript/mastodon/selectors/filters.ts
+++ b/app/javascript/mastodon/selectors/filters.ts
@@ -3,6 +3,14 @@ import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'mastodon/store';
import { toServerSideType } from 'mastodon/utils/filters';
+export interface FilterShape {
+ id: string;
+ title: string;
+ context: string[];
+ expires_at: string | null;
+ filter_action: 'hide' | 'blur' | 'warn';
+}
+
// TODO: move to `app/javascript/mastodon/models` and use more globally
type Filter = Immutable.Map;
@@ -12,7 +20,7 @@ type FilterResult = Immutable.Map;
export const getFilters = createSelector(
[
(state: RootState) => state.filters as Immutable.Map,
- (_, { contextType }: { contextType: string }) => contextType,
+ (_, { contextType }: { contextType?: string }) => contextType,
],
(filters, contextType) => {
if (!contextType) {
diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts
index dde0b8b2270..4f9962d09f7 100644
--- a/app/javascript/mastodon/selectors/statuses.ts
+++ b/app/javascript/mastodon/selectors/statuses.ts
@@ -2,7 +2,11 @@ import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { createAppSelector } from 'mastodon/store';
-import type { StatusShape } from '../models/status';
+import type { ExpandedStatusShape, StatusShape } from '../models/status';
+
+import { selectPlainAccount } from './accounts';
+import type { FilterShape } from './filters';
+import { getFilters } from './filters';
export const getStatusList = createAppSelector(
[
@@ -21,3 +25,84 @@ export const selectPlainStatus = createAppSelector(
return status.toJS() as unknown as StatusShape;
},
);
+
+export const selectAccountStatus = createAppSelector(
+ [
+ selectPlainStatus,
+ (state, statusId: string) => {
+ const accountId = state.statuses.getIn(statusId, 'account');
+ if (typeof accountId !== 'string') {
+ return null;
+ }
+ return selectPlainAccount(state, accountId);
+ },
+ ],
+ (status, account) => {
+ if (!status || !account) {
+ return null;
+ }
+ return {
+ ...status,
+ account,
+ };
+ },
+);
+
+export const selectExpandedStatus = createAppSelector(
+ [
+ selectAccountStatus,
+ (state, statusId: string) => {
+ const reblogId = state.statuses.getIn(statusId, 'reblog');
+ if (typeof reblogId !== 'string') {
+ return null;
+ }
+ return selectAccountStatus(state, reblogId);
+ },
+ ],
+ (status, reblog): ExpandedStatusShape | null => {
+ if (!status) {
+ return null;
+ }
+
+ return {
+ ...status,
+ reblog: reblog ?? undefined,
+ };
+ },
+);
+
+export const selectPictureInPicture = createAppSelector(
+ [
+ (state, statusId: string) =>
+ state.picture_in_picture.type !== null &&
+ state.picture_in_picture.statusId === statusId,
+ (state) => state.meta.get('layout') !== 'mobile',
+ ],
+ (inUse, available) => ({
+ inUse: inUse && available,
+ available,
+ }),
+);
+
+export const selectMediaMatchFilters = createAppSelector(
+ [
+ (state, { statusId }: { statusId: string }) =>
+ selectPlainStatus(state, statusId),
+ getFilters,
+ ],
+ (status, immutableFilters) => {
+ const filters = immutableFilters
+ ? (immutableFilters.toJS() as unknown as Record)
+ : null;
+ const mediaFilters: string[] = [];
+ if (status?.filtered && filters) {
+ for (const { filter } of status.filtered) {
+ if (filters[filter]?.filter_action === 'blur') {
+ mediaFilters.push(filters[filter].title);
+ }
+ }
+ }
+
+ return mediaFilters;
+ },
+);