diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 5d584b021e..9fb4846803 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -17,9 +17,13 @@ export const fetchNotifications = createDataLoadingThunk( fetchedAccounts.push(...notification.sample_accounts); } - // if (notification.type === 'admin.report') { - // fetchedAccounts.push(...notification.report.target_account); - // } + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } if ('status' in notification) { fetchedStatuses.push(notification.status); diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 57f75f973d..fe729c0ee4 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -1,6 +1,9 @@ // See app/serializers/rest/notification_group_serializer.rb +import type { AccountWarningAction } from 'mastodon/models/notification_group'; + import type { ApiAccountJSON } from './accounts'; +import type { ApiReportJSON } from './reports'; import type { ApiStatusJSON } from './statuses'; // See app/model/notification.rb @@ -26,7 +29,7 @@ export interface BaseNotificationGroupJSON { notifications_count: number; type: NotificationType; sample_accounts: ApiAccountJSON[]; - latest_page_notification_at?: string; + latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly page_min_id?: string; page_max_id?: string; } @@ -38,19 +41,39 @@ interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'admin.report'; - report: unknown; + report: ApiReportJSON; +} + +export interface ApiAccountWarningJSON { + id: string; + action: AccountWarningAction; + text: string; + status_ids: string[]; + created_at: string; + target_account: ApiAccountJSON; + appeal: unknown; } interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; - moderation_warning: unknown; + moderation_warning: ApiAccountWarningJSON; +} + +export interface ApiAccountRelationshipSeveranceEventJSON { + id: string; + type: 'account_suspension' | 'domain_block' | 'user_domain_block'; + purged: boolean; + target_name: string; + followers_count: number; + following_count: number; + created_at: string; } interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'severed_relationships'; - account_relationship_severance_event: unknown; + event: ApiAccountRelationshipSeveranceEventJSON; } export type NotificationGroupJSON = diff --git a/app/javascript/mastodon/api_types/reports.ts b/app/javascript/mastodon/api_types/reports.ts new file mode 100644 index 0000000000..b11cfdd2eb --- /dev/null +++ b/app/javascript/mastodon/api_types/reports.ts @@ -0,0 +1,16 @@ +import type { ApiAccountJSON } from './accounts'; + +export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation'; + +export interface ApiReportJSON { + id: string; + action_taken: unknown; + action_taken_at: unknown; + category: ReportCategory; + comment: string; + forwarded: boolean; + created_at: string; + status_ids: string[]; + rule_ids: string[]; + target_account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx index fcb5138820..827ec3b378 100644 --- a/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx +++ b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import GavelIcon from '@/material-icons/400-24px/gavel.svg?react'; import { Icon } from 'mastodon/components/icon'; +import type { AccountWarningAction } from 'mastodon/models/notification_group'; // This needs to be kept in sync with app/models/account_warning.rb const messages = defineMessages({ @@ -38,17 +39,10 @@ const messages = defineMessages({ }); interface Props { - action: - | 'none' - | 'disable' - | 'mark_statuses_as_sensitive' - | 'delete_statuses' - | 'sensitive' - | 'silence' - | 'suspend'; + action: AccountWarningAction; id: string; - hidden: boolean; - unread: boolean; + hidden?: boolean; + unread?: boolean; } export const ModerationWarning: React.FC = ({ @@ -70,7 +64,7 @@ export const ModerationWarning: React.FC = ({ 'notification-group notification-group--link notification-group--moderation-warning focusable', { 'notification-group--unread': unread }, )} - tabIndex='0' + tabIndex={0} >
diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index 561f241d88..0881e24e3f 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -1,8 +1,10 @@ import { useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; + import { FormattedMessage } from 'react-intl'; -import type { List } from 'immutable'; +import { useHistory } from 'react-router-dom'; + +import type { List as ImmutableList, RecordOf } from 'immutable'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; @@ -11,8 +13,11 @@ import { DisplayName } from 'mastodon/components/display_name'; import { Icon } from 'mastodon/components/icon'; import type { Status } from 'mastodon/models/status'; import { useAppSelector } from 'mastodon/store'; + import { EmbeddedStatusContent } from './embedded_status_content'; +export type Mention = RecordOf<{ url: string; acct: string }>; + export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ statusId, }) => { @@ -27,6 +32,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ ); const handleClick = useCallback(() => { + if (!account) return; + history.push(`/@${account.acct}/${statusId}`); }, [statusId, account, history]); @@ -38,9 +45,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ const contentHtml = status.get('contentHtml') as string; const poll = status.get('poll'); const language = status.get('language') as string; - const mentions = status.get('mentions'); + const mentions = status.get('mentions') as ImmutableList; const mediaAttachmentsSize = ( - status.get('media_attachments') as List + status.get('media_attachments') as ImmutableList ).size; return ( @@ -62,7 +69,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
{!!poll && ( <> - + = ({ )} {mediaAttachmentsSize > 0 && ( <> - + { +import type { List } from 'immutable'; + +import type { History } from 'history'; + +import type { Mention } from './embedded_status'; + +const handleMentionClick = ( + history: History, + mention: Mention, + e: MouseEvent, +) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); history.push(`/@${mention.get('acct')}`); } }; -const handleHashtagClick = (history: unknown, hashtag: string, e: Event) => { +const handleHashtagClick = ( + history: History, + hashtag: string, + e: MouseEvent, +) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); history.push(`/tags/${hashtag.replace(/^#/, '')}`); @@ -17,22 +32,22 @@ const handleHashtagClick = (history: unknown, hashtag: string, e: Event) => { export const EmbeddedStatusContent: React.FC<{ content: string; - mentions: unknown; + mentions: List; language: string; - onClick?: unknown; + onClick?: () => void; className?: string; }> = ({ content, mentions, language, onClick, className }) => { - const clickCoordinatesRef = useRef(); + const clickCoordinatesRef = useRef<[number, number] | null>(); const history = useHistory(); - const handleMouseDown = useCallback( + const handleMouseDown = useCallback>( ({ clientX, clientY }) => { clickCoordinatesRef.current = [clientX, clientY]; }, [clickCoordinatesRef], ); - const handleMouseUp = useCallback( + const handleMouseUp = useCallback>( ({ clientX, clientY, target, button }) => { const [startX, startY] = clickCoordinatesRef.current ?? [0, 0]; const [deltaX, deltaY] = [ @@ -40,7 +55,7 @@ export const EmbeddedStatusContent: React.FC<{ Math.abs(clientY - startY), ]; - let element = target; + let element: HTMLDivElement | null = target as HTMLDivElement; while (element) { if ( @@ -51,7 +66,7 @@ export const EmbeddedStatusContent: React.FC<{ return; } - element = element.parentNode; + element = element.parentNode as HTMLDivElement | null; } if (deltaX + deltaY < 5 && button === 0 && onClick) { @@ -63,29 +78,39 @@ export const EmbeddedStatusContent: React.FC<{ [clickCoordinatesRef, onClick], ); - const handleMouseEnter = useCallback(({ currentTarget }) => { - const emojis = currentTarget.querySelectorAll('.custom-emoji'); + const handleMouseEnter = useCallback>( + ({ currentTarget }) => { + const emojis = + currentTarget.querySelectorAll('.custom-emoji'); - for (const emoji of emojis) { - emoji.src = emoji.getAttribute('data-original'); - } - }, []); + for (const emoji of emojis) { + const newSrc = emoji.getAttribute('data-original'); + if (newSrc) emoji.src = newSrc; + } + }, + [], + ); - const handleMouseLeave = useCallback(({ currentTarget }) => { - const emojis = currentTarget.querySelectorAll('.custom-emoji'); + const handleMouseLeave = useCallback>( + ({ currentTarget }) => { + const emojis = + currentTarget.querySelectorAll('.custom-emoji'); - for (const emoji of emojis) { - emoji.src = emoji.getAttribute('data-static'); - } - }, []); + for (const emoji of emojis) { + const newSrc = emoji.getAttribute('data-static'); + if (newSrc) emoji.src = newSrc; + } + }, + [], + ); const handleContentRef = useCallback( - (node) => { + (node: HTMLDivElement | null) => { if (!node) { return; } - const links = node.querySelectorAll('a'); + const links = node.querySelectorAll('a'); for (const link of links) { if (link.classList.contains('status-link')) { @@ -105,12 +130,8 @@ export const EmbeddedStatusContent: React.FC<{ link.setAttribute('title', `@${mention.get('acct')}`); link.setAttribute('href', `/@${mention.get('acct')}`); } else if ( - link.textContent[0] === '#' || - (link.previousSibling && - link.previousSibling.textContent && - link.previousSibling.textContent[ - link.previousSibling.textContent.length - 1 - ] === '#') + link.textContent?.[0] === '#' || + link.previousSibling?.textContent?.endsWith('#') ) { link.addEventListener( 'click', @@ -130,7 +151,7 @@ export const EmbeddedStatusContent: React.FC<{ return (
= ({ notification, notification: { report }, unread }) => { const intl = useIntl(); const targetAccount = useAppSelector((state) => - state.getIn(['accounts', report.target_account.id]), + state.accounts.get(report.targetAccountId), ); const account = useAppSelector((state) => - state.getIn(['accounts', notification.sampleAccountsIds[0]]), + state.accounts.get(notification.sampleAccountsIds[0] ?? '0'), ); + + if (!account || !targetAccount) return null; + const values = { name: ( void; + onMoveDown: (groupId: string) => void; }> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => { const notificationGroup = useAppSelector((state) => state.notificationsGroups.groups.find( diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index b99d2691d2..76d038d6a2 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -16,6 +16,7 @@ export type LabelRenderer = ( export const NotificationGroupWithStatus: React.FC<{ icon: IconProp; + iconId: string; statusId?: string; count: number; accountIds: string[]; @@ -25,6 +26,7 @@ export const NotificationGroupWithStatus: React.FC<{ unread: boolean; }> = ({ icon, + iconId, timestamp, accountIds, count, @@ -48,10 +50,10 @@ export const NotificationGroupWithStatus: React.FC<{ `notification-group focusable notification-group--${type}`, { 'notification-group--unread': unread }, )} - tabIndex='0' + tabIndex={0} >
- +
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx index 856b994257..0b9f3f922d 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx @@ -21,6 +21,7 @@ export const NotificationMention: React.FC<{ = ({ notification: { event }, unread }) => ( - +}> = ({ notification: { moderationWarning }, unread }) => ( + ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx index 0190c3a29f..7d768a7278 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx @@ -27,6 +27,7 @@ export const NotificationPoll: React.FC<{ = ({ icon, accountIds, statusId, count, labelRenderer, type, unread }) => { +}> = ({ + icon, + iconId, + accountIds, + statusId, + count, + labelRenderer, + type, + unread, +}) => { const label = useMemo( () => labelRenderer({ @@ -32,11 +43,11 @@ export const NotificationWithStatus: React.FC<{ `notification-ungrouped focusable notification-ungrouped--${type}`, { 'notification-ungrouped--unread': unread }, )} - tabIndex='0' + tabIndex={0} >
- +
{label}
diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index 58a1ebf849..78dd1b948a 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -1,9 +1,12 @@ import type { + ApiAccountRelationshipSeveranceEventJSON, + ApiAccountWarningJSON, BaseNotificationGroupJSON, NotificationGroupJSON, NotificationType, NotificationWithStatusType, } from 'mastodon/api_types/notifications'; +import type { ApiReportJSON } from 'mastodon/api_types/reports'; interface BaseNotificationGroup extends Omit { @@ -32,12 +35,39 @@ export type NotificationGroupFollow = BaseNotification<'follow'>; export type NotificationGroupFollowRequest = BaseNotification<'follow_request'>; export type NotificationGroupAdminSignUp = BaseNotification<'admin.sign_up'>; -// TODO: those two will need special types -export type NotificationGroupModerationWarning = - BaseNotification<'moderation_warning'>; -export type NotificationGroupAdminReport = BaseNotification<'admin.report'>; -export type NotificationGroupSeveredRelationships = - BaseNotification<'severed_relationships'>; +export type AccountWarningAction = + | 'none' + | 'disable' + | 'mark_statuses_as_sensitive' + | 'delete_statuses' + | 'sensitive' + | 'silence' + | 'suspend'; +export interface AccountWarning + extends Omit { + targetAccountId: string; +} + +export interface NotificationGroupModerationWarning + extends BaseNotification<'moderation_warning'> { + moderationWarning: AccountWarning; +} + +type AccountRelationshipSeveranceEvent = + ApiAccountRelationshipSeveranceEventJSON; +export interface NotificationGroupSeveredRelationships + extends BaseNotification<'severed_relationships'> { + event: AccountRelationshipSeveranceEvent; +} + +interface Report extends Omit { + targetAccountId: string; +} + +export interface NotificationGroupAdminReport + extends BaseNotification<'admin.report'> { + report: Report; +} export type NotificationGroup = | NotificationGroupFavourite @@ -53,6 +83,30 @@ export type NotificationGroup = | NotificationGroupAdminSignUp | NotificationGroupAdminReport; +function createReportFromJSON(reportJSON: ApiReportJSON): Report { + const { target_account, ...report } = reportJSON; + return { + targetAccountId: target_account.id, + ...report, + }; +} + +function createAccountWarningFromJSON( + warningJSON: ApiAccountWarningJSON, +): AccountWarning { + const { target_account, ...warning } = warningJSON; + return { + targetAccountId: target_account.id, + ...warning, + }; +} + +function createAccountRelationshipSeveranceEventFromJSON( + eventJson: ApiAccountRelationshipSeveranceEventJSON, +): AccountRelationshipSeveranceEvent { + return eventJson; +} + export function createNotificationGroupFromJSON( groupJson: NotificationGroupJSON, ): NotificationGroup { @@ -68,8 +122,36 @@ export function createNotificationGroupFromJSON( }; } - return { - sampleAccountsIds, - ...group, - }; + if ('report' in group) { + const { report, ...groupWithoutTargetAccount } = group; + return { + report: createReportFromJSON(report), + sampleAccountsIds, + ...groupWithoutTargetAccount, + }; + } + + switch (group.type) { + case 'severed_relationships': + return { + ...group, + event: createAccountRelationshipSeveranceEventFromJSON(group.event), + sampleAccountsIds, + }; + + case 'moderation_warning': { + const { moderation_warning, ...groupWithoutModerationWarning } = group; + return { + ...groupWithoutModerationWarning, + moderationWarning: createAccountWarningFromJSON(moderation_warning), + sampleAccountsIds, + }; + } + // This is commented out because all group types are covered in the previous statement and have their returns + // default: + // return { + // sampleAccountsIds, + // ...group, + // }; + } }