diff --git a/app/javascript/flavours/glitch/actions/app.ts b/app/javascript/flavours/glitch/actions/app.ts index 6fbfc07f68c931..be1a5cced20f4d 100644 --- a/app/javascript/flavours/glitch/actions/app.ts +++ b/app/javascript/flavours/glitch/actions/app.ts @@ -2,6 +2,9 @@ import { createAction } from '@reduxjs/toolkit'; import type { LayoutType } from '../is_mobile'; +export const focusApp = createAction('APP_FOCUS'); +export const unfocusApp = createAction('APP_UNFOCUS'); + interface ChangeLayoutPayload { layout: LayoutType; } diff --git a/app/javascript/flavours/glitch/actions/markers.ts b/app/javascript/flavours/glitch/actions/markers.ts index a85af1c4beec48..861eae41ec4f1e 100644 --- a/app/javascript/flavours/glitch/actions/markers.ts +++ b/app/javascript/flavours/glitch/actions/markers.ts @@ -75,9 +75,17 @@ interface MarkerParam { } function getLastNotificationId(state: RootState): string | undefined { - // @ts-expect-error state.notifications is not yet typed - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call - return state.getIn(['notifications', 'lastReadId']); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const enableBeta = state.settings.getIn( + ['notifications', 'groupingBeta'], + false, + ) as boolean; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return enableBeta + ? state.notificationGroups.lastReadId + : // @ts-expect-error state.notifications is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + state.getIn(['notifications', 'lastReadId']); } const buildPostMarkersParams = (state: RootState) => { diff --git a/app/javascript/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts new file mode 100644 index 00000000000000..3f6d14a978b154 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -0,0 +1,144 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiClearNotifications, + apiFetchNotifications, +} from 'flavours/glitch/api/notifications'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, +} from 'flavours/glitch/api_types/notifications'; +import { allNotificationTypes } from 'flavours/glitch/api_types/notifications'; +import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; +import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsQuickFilterActive, +} from 'flavours/glitch/selectors/settings'; +import type { AppDispatch } from 'flavours/glitch/store'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { NOTIFICATIONS_FILTER_SET } from './notifications'; +import { saveSettings } from './settings'; + +function excludeAllTypesExcept(filter: string) { + return allNotificationTypes.filter((item) => item !== filter); +} + +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if ('sample_accounts' in notification) { + fetchedAccounts.push(...notification.sample_accounts); + } + + 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); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +export const fetchNotifications = createDataLoadingThunk( + 'notificationGroups/fetch', + async (_params, { getState }) => { + const activeFilter = + selectSettingsNotificationsQuickFilterActive(getState()); + + return apiFetchNotifications({ + exclude_types: + activeFilter === 'all' + ? selectSettingsNotificationsExcludedTypes(getState()) + : excludeAllTypesExcept(activeFilter), + }); + }, + ({ notifications }, { dispatch }) => { + dispatchAssociatedRecords(dispatch, notifications); + const payload: (ApiNotificationGroupJSON | NotificationGap)[] = + notifications; + + // TODO: might be worth not using gaps for that… + // if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri }); + if (notifications.length > 1) + payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id }); + + return payload; + // dispatch(submitMarkers()); + }, +); + +export const fetchNotificationsGap = createDataLoadingThunk( + 'notificationGroups/fetchGap', + async (params: { gap: NotificationGap }) => + apiFetchNotifications({ max_id: params.gap.maxId }), + + ({ notifications }, { dispatch }) => { + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const processNewNotificationForGroups = createAppAsyncThunk( + 'notificationGroups/processNew', + (notification: ApiNotificationJSON, { dispatch }) => { + dispatchAssociatedRecords(dispatch, [notification]); + + return notification; + }, +); + +export const loadPending = createAction('notificationGroups/loadPending'); + +export const updateScrollPosition = createAction<{ top: boolean }>( + 'notificationGroups/updateScrollPosition', +); + +export const setNotificationsFilter = createAppAsyncThunk( + 'notifications/filter/set', + ({ filterType }: { filterType: string }, { dispatch }) => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + // dispatch(expandNotifications({ forceLoad: true })); + void dispatch(fetchNotifications()); + dispatch(saveSettings()); + }, +); + +export const clearNotifications = createDataLoadingThunk( + 'notifications/clear', + () => apiClearNotifications(), +); + +export const markNotificationsAsRead = createAction( + 'notificationGroups/markAsRead', +); + +export const mountNotifications = createAction('notificationGroups/mount'); +export const unmountNotifications = createAction('notificationGroups/unmount'); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 3a1af501c8d9e6..7a97e71bdd22e5 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -43,7 +43,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; @@ -186,7 +185,7 @@ const noOp = () => {}; let expandNotificationsController = new AbortController(); -export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { +export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); @@ -269,16 +268,6 @@ export function expandNotificationsFail(error, isLoadingMore) { }; } -export function clearNotifications() { - return (dispatch) => { - dispatch({ - type: NOTIFICATIONS_CLEAR, - }); - - api().post('/api/v1/notifications/clear'); - }; -} - export function scrollTopNotifications(top) { return { type: NOTIFICATIONS_SCROLL_TOP, diff --git a/app/javascript/flavours/glitch/actions/notifications_migration.tsx b/app/javascript/flavours/glitch/actions/notifications_migration.tsx new file mode 100644 index 00000000000000..32844d4b42e2d6 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications_migration.tsx @@ -0,0 +1,18 @@ +import { createAppAsyncThunk } from 'flavours/glitch/store'; + +import { fetchNotifications } from './notification_groups'; +import { expandNotifications } from './notifications'; + +export const initializeNotifications = createAppAsyncThunk( + 'notifications/initialize', + (_, { dispatch, getState }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const enableBeta = getState().settings.getIn( + ['notifications', 'groupingBeta'], + false, + ) as boolean; + + if (enableBeta) void dispatch(fetchNotifications()); + else dispatch(expandNotifications()); + }, +); diff --git a/app/javascript/flavours/glitch/actions/notifications_typed.ts b/app/javascript/flavours/glitch/actions/notifications_typed.ts index 176362f4b1edd4..55896f4c378f49 100644 --- a/app/javascript/flavours/glitch/actions/notifications_typed.ts +++ b/app/javascript/flavours/glitch/actions/notifications_typed.ts @@ -1,11 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import type { ApiAccountJSON } from '../api_types/accounts'; -// To be replaced once ApiNotificationJSON type exists -interface FakeApiNotificationJSON { - type: string; - account: ApiAccountJSON; -} +import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications'; export const notificationsUpdate = createAction( 'notifications/update', @@ -13,7 +8,7 @@ export const notificationsUpdate = createAction( playSound, ...args }: { - notification: FakeApiNotificationJSON; + notification: ApiNotificationJSON; usePendingItems: boolean; playSound: boolean; }) => ({ diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index a55240646f3e69..7b006c1be7c019 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -10,6 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; +import { processNewNotificationForGroups } from './notification_groups'; import { updateNotifications, expandNotifications } from './notifications'; import { updateStatus } from './statuses'; import { @@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'delete': dispatch(deleteFromTimelines(data.payload)); break; - case 'notification': + case 'notification': { // @ts-expect-error - dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + const notificationJSON = JSON.parse(data.payload); + dispatch(updateNotifications(notificationJSON, messages, locale)); + // TODO: remove this once the groups feature replaces the previous one + if(getState().notificationGroups.groups.length > 0) { + dispatch(processNewNotificationForGroups(notificationJSON)); + } break; + } case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); diff --git a/app/javascript/flavours/glitch/api/notifications.ts b/app/javascript/flavours/glitch/api/notifications.ts new file mode 100644 index 00000000000000..fe718788220d44 --- /dev/null +++ b/app/javascript/flavours/glitch/api/notifications.ts @@ -0,0 +1,18 @@ +import api, { apiRequest, getLinks } from 'flavours/glitch/api'; +import type { ApiNotificationGroupJSON } from 'flavours/glitch/api_types/notifications'; + +export const apiFetchNotifications = async (params?: { + exclude_types?: string[]; + max_id?: string; +}) => { + const response = await api().request({ + method: 'GET', + url: '/api/v2_alpha/notifications', + params, + }); + + return { notifications: response.data, links: getLinks(response) }; +}; + +export const apiClearNotifications = () => + apiRequest('POST', 'v1/notifications/clear'); diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts new file mode 100644 index 00000000000000..ea37556d8d0af0 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -0,0 +1,145 @@ +// See app/serializers/rest/notification_group_serializer.rb + +import type { AccountWarningAction } from 'flavours/glitch/models/notification_group'; + +import type { ApiAccountJSON } from './accounts'; +import type { ApiReportJSON } from './reports'; +import type { ApiStatusJSON } from './statuses'; + +// See app/model/notification.rb +export const allNotificationTypes = [ + 'follow', + 'follow_request', + 'favourite', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + 'admin.sign_up', + 'admin.report', + 'moderation_warning', + 'severed_relationships', +]; + +export type NotificationWithStatusType = + | 'favourite' + | 'reblog' + | 'status' + | 'mention' + | 'poll' + | 'update'; + +export type NotificationType = + | NotificationWithStatusType + | 'follow' + | 'follow_request' + | 'moderation_warning' + | 'severed_relationships' + | 'admin.sign_up' + | 'admin.report'; + +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; +} + +export interface BaseNotificationGroupJSON { + group_key: string; + notifications_count: number; + type: NotificationType; + sample_accounts: ApiAccountJSON[]; + latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly + most_recent_notification_id: string; + page_min_id?: string; + page_max_id?: string; +} + +interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON; +} + +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON; +} + +interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +interface ReportNotificationJSON extends BaseNotificationJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; +interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { + type: SimpleNotificationTypes; +} + +interface SimpleNotificationJSON extends BaseNotificationJSON { + type: SimpleNotificationTypes; +} + +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: ApiAccountWarningJSON; +} + +interface ModerationWarningNotificationJSON extends BaseNotificationJSON { + type: 'moderation_warning'; + 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'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +interface AccountRelationshipSeveranceNotificationJSON + extends BaseNotificationJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +export type ApiNotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + +export type ApiNotificationGroupJSON = + | SimpleNotificationGroupJSON + | ReportNotificationGroupJSON + | AccountRelationshipSeveranceNotificationGroupJSON + | NotificationGroupWithStatusJSON + | ModerationWarningNotificationGroupJSON; diff --git a/app/javascript/flavours/glitch/api_types/reports.ts b/app/javascript/flavours/glitch/api_types/reports.ts new file mode 100644 index 00000000000000..b11cfdd2eb59c8 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/load_gap.tsx b/app/javascript/flavours/glitch/components/load_gap.tsx index f0d15d37761030..1870185c2914d9 100644 --- a/app/javascript/flavours/glitch/components/load_gap.tsx +++ b/app/javascript/flavours/glitch/components/load_gap.tsx @@ -9,18 +9,18 @@ const messages = defineMessages({ load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, }); -interface Props { +interface Props { disabled: boolean; - maxId: string; - onClick: (maxId: string) => void; + param: T; + onClick: (params: T) => void; } -export const LoadGap: React.FC = ({ disabled, maxId, onClick }) => { +export const LoadGap = ({ disabled, param, onClick }: Props) => { const intl = useIntl(); const handleClick = useCallback(() => { - onClick(maxId); - }, [maxId, onClick]); + onClick(param); + }, [param, onClick]); return ( + ); +}; + +export const FilterBar: React.FC = () => { + const intl = useIntl(); + + const selectedFilter = useAppSelector( + selectSettingsNotificationsQuickFilterActive, + ); + const advancedMode = useAppSelector( + selectSettingsNotificationsQuickFilterAdvanced, + ); + + if (advancedMode) + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); + else + return ( +
+ + + + + + +
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/notifications_v2/index.tsx b/app/javascript/flavours/glitch/features/notifications_v2/index.tsx new file mode 100644 index 00000000000000..94051713751779 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications_v2/index.tsx @@ -0,0 +1,354 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { createSelector } from '@reduxjs/toolkit'; + +import { useDebouncedCallback } from 'use-debounce'; + +import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; +import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; +import { + fetchNotificationsGap, + updateScrollPosition, + loadPending, + markNotificationsAsRead, + mountNotifications, + unmountNotifications, +} from 'flavours/glitch/actions/notification_groups'; +import { compareId } from 'flavours/glitch/compare_id'; +import { Icon } from 'flavours/glitch/components/icon'; +import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator'; +import { useIdentity } from 'flavours/glitch/identity_context'; +import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; +import { + selectUnreadNotificationGroupsCount, + selectPendingNotificationGroupsCount, +} from 'flavours/glitch/selectors/notifications'; +import { + selectNeedsNotificationPermission, + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsQuickFilterShow, + selectSettingsNotificationsShowUnread, +} from 'flavours/glitch/selectors/settings'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; +import type { RootState } from 'flavours/glitch/store'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { submitMarkers } from '../../actions/markers'; +import Column from '../../components/column'; +import { ColumnHeader } from '../../components/column_header'; +import { LoadGap } from '../../components/load_gap'; +import ScrollableList from '../../components/scrollable_list'; +import { FilteredNotificationsBanner } from '../notifications/components/filtered_notifications_banner'; +import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner'; +import ColumnSettingsContainer from '../notifications/containers/column_settings_container'; + +import { NotificationGroup } from './components/notification_group'; +import { FilterBar } from './filter_bar'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + markAsRead: { + id: 'notifications.mark_as_read', + defaultMessage: 'Mark every notification as read', + }, +}); + +const getNotifications = createSelector( + [ + selectSettingsNotificationsQuickFilterShow, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsExcludedTypes, + (state: RootState) => state.notificationGroups.groups, + ], + (showFilterBar, allowedType, excludedTypes, notifications) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filter( + (item) => item.type === 'gap' || !excludedTypes.includes(item.type), + ); + } + return notifications.filter( + (item) => item.type === 'gap' || allowedType === item.type, + ); + }, +); + +export const Notifications: React.FC<{ + columnId?: string; + multiColumn?: boolean; +}> = ({ columnId, multiColumn }) => { + const intl = useIntl(); + const notifications = useAppSelector(getNotifications); + const dispatch = useAppDispatch(); + const isLoading = useAppSelector((s) => s.notificationGroups.isLoading); + const hasMore = notifications.at(-1)?.type === 'gap'; + + const lastReadId = useAppSelector((s) => + selectSettingsNotificationsShowUnread(s) + ? s.notificationGroups.lastReadId + : '0', + ); + + const numPending = useAppSelector(selectPendingNotificationGroupsCount); + + const unreadNotificationsCount = useAppSelector( + selectUnreadNotificationGroupsCount, + ); + + const isUnread = unreadNotificationsCount > 0; + + const canMarkAsRead = + useAppSelector(selectSettingsNotificationsShowUnread) && + unreadNotificationsCount > 0; + + const needsNotificationPermission = useAppSelector( + selectNeedsNotificationPermission, + ); + + const columnRef = useRef(null); + + const selectChild = useCallback((index: number, alignTop: boolean) => { + const container = columnRef.current?.node as HTMLElement | undefined; + + if (!container) return; + + const element = container.querySelector( + `article:nth-of-type(${index + 1}) .focusable`, + ); + + if (element) { + if (alignTop && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if ( + !alignTop && + container.scrollTop + container.clientHeight < + element.offsetTop + element.offsetHeight + ) { + element.scrollIntoView(false); + } + element.focus(); + } + }, []); + + // Keep track of mounted components for unread notification handling + useEffect(() => { + dispatch(mountNotifications()); + + return () => { + dispatch(unmountNotifications()); + dispatch(updateScrollPosition({ top: false })); + }; + }, [dispatch]); + + const handleLoadGap = useCallback( + (gap: NotificationGap) => { + void dispatch(fetchNotificationsGap({ gap })); + }, + [dispatch], + ); + + const handleLoadOlder = useDebouncedCallback( + () => { + const gap = notifications.at(-1); + if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap })); + }, + 300, + { leading: true }, + ); + + const handleLoadPending = useCallback(() => { + dispatch(loadPending()); + }, [dispatch]); + + const handleScrollToTop = useDebouncedCallback(() => { + dispatch(updateScrollPosition({ top: true })); + }, 100); + + const handleScroll = useDebouncedCallback(() => { + dispatch(updateScrollPosition({ top: false })); + }, 100); + + useEffect(() => { + return () => { + handleLoadOlder.cancel(); + handleScrollToTop.cancel(); + handleScroll.cancel(); + }; + }, [handleLoadOlder, handleScrollToTop, handleScroll]); + + const handlePin = useCallback(() => { + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + }, [columnId, dispatch]); + + const handleMove = useCallback( + (dir: unknown) => { + dispatch(moveColumn(columnId, dir)); + }, + [dispatch, columnId], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleMoveUp = useCallback( + (id: string) => { + const elementIndex = + notifications.findIndex( + (item) => item.type !== 'gap' && item.group_key === id, + ) - 1; + selectChild(elementIndex, true); + }, + [notifications, selectChild], + ); + + const handleMoveDown = useCallback( + (id: string) => { + const elementIndex = + notifications.findIndex( + (item) => item.type !== 'gap' && item.group_key === id, + ) + 1; + selectChild(elementIndex, false); + }, + [notifications, selectChild], + ); + + const handleMarkAsRead = useCallback(() => { + dispatch(markNotificationsAsRead()); + void dispatch(submitMarkers({ immediate: true })); + }, [dispatch]); + + const pinned = !!columnId; + const emptyMessage = ( + + ); + + const { signedIn } = useIdentity(); + + const filterBar = signedIn ? : null; + + const scrollableContent = useMemo(() => { + if (notifications.length === 0 && !hasMore) return null; + + return notifications.map((item) => + item.type === 'gap' ? ( + + ) : ( + 0 + } + /> + ), + ); + }, [ + notifications, + isLoading, + hasMore, + lastReadId, + handleLoadGap, + handleMoveUp, + handleMoveDown, + ]); + + const prepend = ( + <> + {needsNotificationPermission && } + + + ); + + const scrollContainer = signedIn ? ( + + {scrollableContent} + + ) : ( + + ); + + const extraButton = canMarkAsRead ? ( + + ) : null; + + return ( + + + + + + {filterBar} + + {scrollContainer} + + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Notifications; diff --git a/app/javascript/flavours/glitch/features/notifications_wrapper.jsx b/app/javascript/flavours/glitch/features/notifications_wrapper.jsx new file mode 100644 index 00000000000000..15ab3367cc1bf8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications_wrapper.jsx @@ -0,0 +1,13 @@ +import Notifications from 'flavours/glitch/features/notifications'; +import Notifications_v2 from 'flavours/glitch/features/notifications_v2'; +import { useAppSelector } from 'flavours/glitch/store'; + +export const NotificationsWrapper = (props) => { + const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); + + return ( + optedInGroupedNotifications ? : + ); +}; + +export default NotificationsWrapper; \ No newline at end of file diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx index 9166499c5a08b9..e4c67ed65ef728 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx @@ -10,7 +10,7 @@ import { scrollRight } from '../../../scroll'; import BundleContainer from '../containers/bundle_container'; import { Compose, - Notifications, + NotificationsWrapper, HomeTimeline, CommunityTimeline, PublicTimeline, @@ -32,7 +32,7 @@ import NavigationPanel from './navigation_panel'; const componentMap = { 'COMPOSE': Compose, 'HOME': HomeTimeline, - 'NOTIFICATIONS': Notifications, + 'NOTIFICATIONS': NotificationsWrapper, 'PUBLIC': PublicTimeline, 'REMOTE': PublicTimeline, 'COMMUNITY': CommunityTimeline, diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index 98d82342cf3b6e..6153b3ceaa7c4e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -33,6 +33,7 @@ import { NavigationPortal } from 'flavours/glitch/components/navigation_portal'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; import { transientSingleColumn } from 'flavours/glitch/is_mobile'; +import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { preferencesLink } from 'flavours/glitch/utils/backend_links'; import ColumnLink from './column_link'; @@ -60,15 +61,19 @@ const messages = defineMessages({ }); const NotificationsLink = () => { + const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); const count = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0); const intl = useIntl(); + const newCount = useSelector(selectUnreadNotificationGroupsCount); + return ( } - activeIcon={} + icon={} + activeIcon={} text={intl.formatMessage(messages.notifications)} /> ); diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 221f789a315995..7a2aa21f8812c7 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -12,8 +12,9 @@ import Favico from 'favico.js'; import { debounce } from 'lodash'; import { HotKeys } from 'react-hotkeys'; -import { changeLayout } from 'flavours/glitch/actions/app'; +import { focusApp, unfocusApp, changeLayout } from 'flavours/glitch/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; +import { initializeNotifications } from 'flavours/glitch/actions/notifications_migration'; import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { HoverCardController } from 'flavours/glitch/components/hover_card_controller'; import { Permalink } from 'flavours/glitch/components/permalink'; @@ -24,7 +25,7 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { clearHeight } from '../../actions/height_cache'; -import { expandNotifications, notificationsSetVisibility } from '../../actions/notifications'; +import { notificationsSetVisibility } from '../../actions/notifications'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { expandHomeTimeline } from '../../actions/timelines'; import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; @@ -51,7 +52,7 @@ import { Favourites, DirectTimeline, HashtagTimeline, - Notifications, + NotificationsWrapper, NotificationRequests, NotificationRequest, FollowRequests, @@ -74,6 +75,7 @@ import { } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; + // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; @@ -214,7 +216,7 @@ class SwitchingColumnsArea extends PureComponent { - + @@ -304,7 +306,10 @@ class UI extends PureComponent { const visibility = !document[this.visibilityHiddenProp]; this.props.dispatch(notificationsSetVisibility(visibility)); if (visibility) { + this.props.dispatch(focusApp()); this.props.dispatch(submitMarkers({ immediate: true })); + } else { + this.props.dispatch(unfocusApp()); } }; @@ -419,7 +424,7 @@ class UI extends PureComponent { if (signedIn) { this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); - this.props.dispatch(expandNotifications()); + this.props.dispatch(initializeNotifications()); this.props.dispatch(fetchServerTranslationLanguages()); setTimeout(() => this.props.dispatch(fetchServer()), 3000); diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index a312cefff7dcfb..e334e1a3b6ea38 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -7,7 +7,15 @@ export function Compose () { } export function Notifications () { - return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'../../notifications'); + return import(/* webpackChunkName: "flavours/glitch/async/notifications_v1" */'../../notifications'); +} + +export function Notifications_v2 () { + return import(/* webpackChunkName: "flavours/glitch/async/notifications_v2" */'../../notifications_v2'); +} + +export function NotificationsWrapper () { + return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'../../notifications_wrapper'); } export function HomeTimeline () { diff --git a/app/javascript/flavours/glitch/models/notification_group.ts b/app/javascript/flavours/glitch/models/notification_group.ts new file mode 100644 index 00000000000000..6c8b8eb6e3d555 --- /dev/null +++ b/app/javascript/flavours/glitch/models/notification_group.ts @@ -0,0 +1,207 @@ +import type { + ApiAccountRelationshipSeveranceEventJSON, + ApiAccountWarningJSON, + BaseNotificationGroupJSON, + ApiNotificationGroupJSON, + ApiNotificationJSON, + NotificationType, + NotificationWithStatusType, +} from 'flavours/glitch/api_types/notifications'; +import type { ApiReportJSON } from 'flavours/glitch/api_types/reports'; + +// Maximum number of avatars displayed in a notification group +// This corresponds to the max lenght of `group.sampleAccountIds` +export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8; + +interface BaseNotificationGroup + extends Omit { + sampleAccountIds: string[]; +} + +interface BaseNotificationWithStatus + extends BaseNotificationGroup { + type: Type; + statusId: string; +} + +interface BaseNotification + extends BaseNotificationGroup { + type: Type; +} + +export type NotificationGroupFavourite = + BaseNotificationWithStatus<'favourite'>; +export type NotificationGroupReblog = BaseNotificationWithStatus<'reblog'>; +export type NotificationGroupStatus = BaseNotificationWithStatus<'status'>; +export type NotificationGroupMention = BaseNotificationWithStatus<'mention'>; +export type NotificationGroupPoll = BaseNotificationWithStatus<'poll'>; +export type NotificationGroupUpdate = BaseNotificationWithStatus<'update'>; +export type NotificationGroupFollow = BaseNotification<'follow'>; +export type NotificationGroupFollowRequest = BaseNotification<'follow_request'>; +export type NotificationGroupAdminSignUp = BaseNotification<'admin.sign_up'>; + +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 + | NotificationGroupReblog + | NotificationGroupStatus + | NotificationGroupMention + | NotificationGroupPoll + | NotificationGroupUpdate + | NotificationGroupFollow + | NotificationGroupFollowRequest + | NotificationGroupModerationWarning + | NotificationGroupSeveredRelationships + | 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: ApiNotificationGroupJSON, +): NotificationGroup { + const { sample_accounts, ...group } = groupJson; + const sampleAccountIds = sample_accounts.map((account) => account.id); + + switch (group.type) { + case 'favourite': + case 'reblog': + case 'status': + case 'mention': + case 'poll': + case 'update': { + const { status, ...groupWithoutStatus } = group; + return { + statusId: status.id, + sampleAccountIds, + ...groupWithoutStatus, + }; + } + case 'admin.report': { + const { report, ...groupWithoutTargetAccount } = group; + return { + report: createReportFromJSON(report), + sampleAccountIds, + ...groupWithoutTargetAccount, + }; + } + case 'severed_relationships': + return { + ...group, + event: createAccountRelationshipSeveranceEventFromJSON(group.event), + sampleAccountIds, + }; + + case 'moderation_warning': { + const { moderation_warning, ...groupWithoutModerationWarning } = group; + return { + ...groupWithoutModerationWarning, + moderationWarning: createAccountWarningFromJSON(moderation_warning), + sampleAccountIds, + }; + } + default: + return { + sampleAccountIds, + ...group, + }; + } +} + +export function createNotificationGroupFromNotificationJSON( + notification: ApiNotificationJSON, +) { + const group = { + sampleAccountIds: [notification.account.id], + group_key: notification.group_key, + notifications_count: 1, + type: notification.type, + most_recent_notification_id: notification.id, + page_min_id: notification.id, + page_max_id: notification.id, + latest_page_notification_at: notification.created_at, + } as NotificationGroup; + + switch (notification.type) { + case 'favourite': + case 'reblog': + case 'status': + case 'mention': + case 'poll': + case 'update': + return { ...group, statusId: notification.status.id }; + case 'admin.report': + return { ...group, report: createReportFromJSON(notification.report) }; + case 'severed_relationships': + return { + ...group, + event: createAccountRelationshipSeveranceEventFromJSON( + notification.event, + ), + }; + case 'moderation_warning': + return { + ...group, + moderationWarning: createAccountWarningFromJSON( + notification.moderation_warning, + ), + }; + default: + return group; + } +} diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index 3fee5818f981d5..d8d50be7d21621 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -25,6 +25,7 @@ import { markersReducer } from './markers'; import media_attachments from './media_attachments'; import meta from './meta'; import { modalReducer } from './modal'; +import { notificationGroupsReducer } from './notification_groups'; import { notificationPolicyReducer } from './notification_policy'; import { notificationRequestsReducer } from './notification_requests'; import notifications from './notifications'; @@ -68,6 +69,7 @@ const reducers = { search, media_attachments, notifications, + notificationGroups: notificationGroupsReducer, height_cache, custom_emojis, lists, diff --git a/app/javascript/flavours/glitch/reducers/markers.ts b/app/javascript/flavours/glitch/reducers/markers.ts index 380e9d39952378..0f9dc3a317b249 100644 --- a/app/javascript/flavours/glitch/reducers/markers.ts +++ b/app/javascript/flavours/glitch/reducers/markers.ts @@ -1,6 +1,7 @@ import { createReducer } from '@reduxjs/toolkit'; -import { submitMarkersAction } from 'flavours/glitch/actions/markers'; +import { submitMarkersAction, fetchMarkers } from 'flavours/glitch/actions/markers'; +import { compareId } from 'flavours/glitch/compare_id'; const initialState = { home: '0', @@ -15,4 +16,23 @@ export const markersReducer = createReducer(initialState, (builder) => { if (notifications) state.notifications = notifications; }, ); + builder.addCase( + fetchMarkers.fulfilled, + ( + state, + { + payload: { + markers: { home, notifications }, + }, + }, + ) => { + if (home && compareId(home.last_read_id, state.home) > 0) + state.home = home.last_read_id; + if ( + notifications && + compareId(notifications.last_read_id, state.notifications) > 0 + ) + state.notifications = notifications.last_read_id; + }, + ); }); diff --git a/app/javascript/flavours/glitch/reducers/notification_groups.ts b/app/javascript/flavours/glitch/reducers/notification_groups.ts new file mode 100644 index 00000000000000..b0b284b696e4bb --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/notification_groups.ts @@ -0,0 +1,508 @@ +import { createReducer, isAnyOf } from '@reduxjs/toolkit'; + +import { + authorizeFollowRequestSuccess, + blockAccountSuccess, + muteAccountSuccess, + rejectFollowRequestSuccess, +} from 'flavours/glitch/actions/accounts_typed'; +import { focusApp, unfocusApp } from 'flavours/glitch/actions/app'; +import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks_typed'; +import { fetchMarkers } from 'flavours/glitch/actions/markers'; +import { + clearNotifications, + fetchNotifications, + fetchNotificationsGap, + processNewNotificationForGroups, + loadPending, + updateScrollPosition, + markNotificationsAsRead, + mountNotifications, + unmountNotifications, +} from 'flavours/glitch/actions/notification_groups'; +import { + disconnectTimeline, + timelineDelete, +} from 'flavours/glitch/actions/timelines_typed'; +import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications'; +import { compareId } from 'flavours/glitch/compare_id'; +import { usePendingItems } from 'flavours/glitch/initial_state'; +import { + NOTIFICATIONS_GROUP_MAX_AVATARS, + createNotificationGroupFromJSON, + createNotificationGroupFromNotificationJSON, +} from 'flavours/glitch/models/notification_group'; +import type { NotificationGroup } from 'flavours/glitch/models/notification_group'; + +const NOTIFICATIONS_TRIM_LIMIT = 50; + +export interface NotificationGap { + type: 'gap'; + maxId?: string; + sinceId?: string; +} + +interface NotificationGroupsState { + groups: (NotificationGroup | NotificationGap)[]; + pendingGroups: (NotificationGroup | NotificationGap)[]; + scrolledToTop: boolean; + isLoading: boolean; + lastReadId: string; + mounted: number; + isTabVisible: boolean; +} + +const initialState: NotificationGroupsState = { + groups: [], + pendingGroups: [], // holds pending groups in slow mode + scrolledToTop: false, + isLoading: false, + // The following properties are used to track unread notifications + lastReadId: '0', // used for unread notifications + mounted: 0, // number of mounted notification list components, usually 0 or 1 + isTabVisible: true, +}; + +function filterNotificationsForAccounts( + groups: NotificationGroupsState['groups'], + accountIds: string[], + onlyForType?: string, +) { + groups = groups + .map((group) => { + if ( + group.type !== 'gap' && + (!onlyForType || group.type === onlyForType) + ) { + const previousLength = group.sampleAccountIds.length; + + group.sampleAccountIds = group.sampleAccountIds.filter( + (id) => !accountIds.includes(id), + ); + + const newLength = group.sampleAccountIds.length; + const removed = previousLength - newLength; + + group.notifications_count -= removed; + } + + return group; + }) + .filter( + (group) => group.type === 'gap' || group.sampleAccountIds.length > 0, + ); + mergeGaps(groups); + return groups; +} + +function filterNotificationsForStatus( + groups: NotificationGroupsState['groups'], + statusId: string, +) { + groups = groups.filter( + (group) => + group.type === 'gap' || + !('statusId' in group) || + group.statusId !== statusId, + ); + mergeGaps(groups); + return groups; +} + +function removeNotificationsForAccounts( + state: NotificationGroupsState, + accountIds: string[], + onlyForType?: string, +) { + state.groups = filterNotificationsForAccounts( + state.groups, + accountIds, + onlyForType, + ); + state.pendingGroups = filterNotificationsForAccounts( + state.pendingGroups, + accountIds, + onlyForType, + ); +} + +function removeNotificationsForStatus( + state: NotificationGroupsState, + statusId: string, +) { + state.groups = filterNotificationsForStatus(state.groups, statusId); + state.pendingGroups = filterNotificationsForStatus( + state.pendingGroups, + statusId, + ); +} + +function isNotificationGroup( + groupOrGap: NotificationGroup | NotificationGap, +): groupOrGap is NotificationGroup { + return groupOrGap.type !== 'gap'; +} + +// Merge adjacent gaps in `groups` in-place +function mergeGaps(groups: NotificationGroupsState['groups']) { + for (let i = 0; i < groups.length; i++) { + const firstGroupOrGap = groups[i]; + + if (firstGroupOrGap?.type === 'gap') { + let lastGap = firstGroupOrGap; + let j = i + 1; + + for (; j < groups.length; j++) { + const groupOrGap = groups[j]; + if (groupOrGap?.type === 'gap') lastGap = groupOrGap; + else break; + } + + if (j - i > 1) { + groups.splice(i, j - i, { + type: 'gap', + maxId: firstGroupOrGap.maxId, + sinceId: lastGap.sinceId, + }); + } + } + } +} + +// Checks if `groups[index-1]` and `groups[index]` are gaps, and merge them in-place if they are +function mergeGapsAround( + groups: NotificationGroupsState['groups'], + index: number, +) { + if (index > 0) { + const potentialFirstGap = groups[index - 1]; + const potentialSecondGap = groups[index]; + + if ( + potentialFirstGap?.type === 'gap' && + potentialSecondGap?.type === 'gap' + ) { + groups.splice(index - 1, 2, { + type: 'gap', + maxId: potentialFirstGap.maxId, + sinceId: potentialSecondGap.sinceId, + }); + } + } +} + +function processNewNotification( + groups: NotificationGroupsState['groups'], + notification: ApiNotificationJSON, +) { + const existingGroupIndex = groups.findIndex( + (group) => + group.type !== 'gap' && group.group_key === notification.group_key, + ); + + // In any case, we are going to add a group at the top + // If there is currently a gap at the top, now is the time to update it + if (groups.length > 0 && groups[0]?.type === 'gap') { + groups[0].maxId = notification.id; + } + + if (existingGroupIndex > -1) { + const existingGroup = groups[existingGroupIndex]; + + if ( + existingGroup && + existingGroup.type !== 'gap' && + !existingGroup.sampleAccountIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post + ) { + // Update the existing group + if ( + existingGroup.sampleAccountIds.unshift(notification.account.id) > + NOTIFICATIONS_GROUP_MAX_AVATARS + ) + existingGroup.sampleAccountIds.pop(); + + existingGroup.most_recent_notification_id = notification.id; + existingGroup.page_max_id = notification.id; + existingGroup.latest_page_notification_at = notification.created_at; + existingGroup.notifications_count += 1; + + groups.splice(existingGroupIndex, 1); + mergeGapsAround(groups, existingGroupIndex); + + groups.unshift(existingGroup); + } + } else { + // Create a new group + groups.unshift(createNotificationGroupFromNotificationJSON(notification)); + } +} + +function trimNotifications(state: NotificationGroupsState) { + if (state.scrolledToTop) { + state.groups.splice(NOTIFICATIONS_TRIM_LIMIT); + } +} + +function shouldMarkNewNotificationsAsRead( + { + isTabVisible, + scrolledToTop, + mounted, + lastReadId, + groups, + }: NotificationGroupsState, + ignoreScroll = false, +) { + const isMounted = mounted > 0; + const oldestGroup = groups.findLast(isNotificationGroup); + const hasMore = groups.at(-1)?.type === 'gap'; + const oldestGroupReached = + !hasMore || + lastReadId === '0' || + (oldestGroup?.page_min_id && + compareId(oldestGroup.page_min_id, lastReadId) <= 0); + + return ( + isTabVisible && + (ignoreScroll || scrolledToTop) && + isMounted && + oldestGroupReached + ); +} + +function updateLastReadId( + state: NotificationGroupsState, + group: NotificationGroup | undefined = undefined, +) { + if (shouldMarkNewNotificationsAsRead(state)) { + group = group ?? state.groups.find(isNotificationGroup); + if ( + group?.page_max_id && + compareId(state.lastReadId, group.page_max_id) < 0 + ) + state.lastReadId = group.page_max_id; + } +} + +export const notificationGroupsReducer = createReducer( + initialState, + (builder) => { + builder + .addCase(fetchNotifications.fulfilled, (state, action) => { + state.groups = action.payload.map((json) => + json.type === 'gap' ? json : createNotificationGroupFromJSON(json), + ); + state.isLoading = false; + updateLastReadId(state); + }) + .addCase(fetchNotificationsGap.fulfilled, (state, action) => { + const { notifications } = action.payload; + + // find the gap in the existing notifications + const gapIndex = state.groups.findIndex( + (groupOrGap) => + groupOrGap.type === 'gap' && + groupOrGap.sinceId === action.meta.arg.gap.sinceId && + groupOrGap.maxId === action.meta.arg.gap.maxId, + ); + + if (gapIndex < 0) + // We do not know where to insert, let's return + return; + + // Filling a disconnection gap means we're getting historical data + // about groups we may know or may not know about. + + // The notifications timeline is split in two by the gap, with + // group information newer than the gap, and group information older + // than the gap. + + // Filling a gap should not touch anything before the gap, so any + // information on groups already appearing before the gap should be + // discarded, while any information on groups appearing after the gap + // can be updated and re-ordered. + + const oldestPageNotification = notifications.at(-1)?.page_min_id; + + // replace the gap with the notifications + a new gap + + const newerGroupKeys = state.groups + .slice(0, gapIndex) + .filter(isNotificationGroup) + .map((group) => group.group_key); + + const toInsert: NotificationGroupsState['groups'] = notifications + .map((json) => createNotificationGroupFromJSON(json)) + .filter( + (notification) => !newerGroupKeys.includes(notification.group_key), + ); + + const apiGroupKeys = (toInsert as NotificationGroup[]).map( + (group) => group.group_key, + ); + + const sinceId = action.meta.arg.gap.sinceId; + if ( + notifications.length > 0 && + !( + oldestPageNotification && + sinceId && + compareId(oldestPageNotification, sinceId) <= 0 + ) + ) { + // If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap + // Similarly, if we've fetched more than the gap's, this means we have completely filled it + toInsert.push({ + type: 'gap', + maxId: notifications.at(-1)?.page_max_id, + sinceId, + } as NotificationGap); + } + + // Remove older groups covered by the API + state.groups = state.groups.filter( + (groupOrGap) => + groupOrGap.type !== 'gap' && + !apiGroupKeys.includes(groupOrGap.group_key), + ); + + // Replace the gap with API results (+ the new gap if needed) + state.groups.splice(gapIndex, 1, ...toInsert); + + // Finally, merge any adjacent gaps that could have been created by filtering + // groups earlier + mergeGaps(state.groups); + + state.isLoading = false; + + updateLastReadId(state); + }) + .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { + const notification = action.payload; + processNewNotification( + usePendingItems ? state.pendingGroups : state.groups, + notification, + ); + updateLastReadId(state); + trimNotifications(state); + }) + .addCase(disconnectTimeline, (state, action) => { + if (action.payload.timeline === 'home') { + if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') { + state.groups.unshift({ + type: 'gap', + sinceId: state.groups[0]?.page_min_id, + }); + } + } + }) + .addCase(timelineDelete, (state, action) => { + removeNotificationsForStatus(state, action.payload.statusId); + }) + .addCase(clearNotifications.pending, (state) => { + state.groups = []; + state.pendingGroups = []; + }) + .addCase(blockAccountSuccess, (state, action) => { + removeNotificationsForAccounts(state, [action.payload.relationship.id]); + }) + .addCase(muteAccountSuccess, (state, action) => { + if (action.payload.relationship.muting_notifications) + removeNotificationsForAccounts(state, [ + action.payload.relationship.id, + ]); + }) + .addCase(blockDomainSuccess, (state, action) => { + removeNotificationsForAccounts( + state, + action.payload.accounts.map((account) => account.id), + ); + }) + .addCase(loadPending, (state) => { + // First, remove any existing group and merge data + state.pendingGroups.forEach((group) => { + if (group.type !== 'gap') { + const existingGroupIndex = state.groups.findIndex( + (groupOrGap) => + isNotificationGroup(groupOrGap) && + groupOrGap.group_key === group.group_key, + ); + if (existingGroupIndex > -1) { + const existingGroup = state.groups[existingGroupIndex]; + if (existingGroup && existingGroup.type !== 'gap') { + group.notifications_count += existingGroup.notifications_count; + group.sampleAccountIds = group.sampleAccountIds + .concat(existingGroup.sampleAccountIds) + .slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS); + state.groups.splice(existingGroupIndex, 1); + } + } + } + trimNotifications(state); + }); + + // Then build the consolidated list and clear pending groups + state.groups = state.pendingGroups.concat(state.groups); + state.pendingGroups = []; + }) + .addCase(updateScrollPosition, (state, action) => { + state.scrolledToTop = action.payload.top; + updateLastReadId(state); + trimNotifications(state); + }) + .addCase(markNotificationsAsRead, (state) => { + const mostRecentGroup = state.groups.find(isNotificationGroup); + if ( + mostRecentGroup?.page_max_id && + compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0 + ) + state.lastReadId = mostRecentGroup.page_max_id; + }) + .addCase(fetchMarkers.fulfilled, (state, action) => { + if ( + action.payload.markers.notifications && + compareId( + state.lastReadId, + action.payload.markers.notifications.last_read_id, + ) < 0 + ) + state.lastReadId = action.payload.markers.notifications.last_read_id; + }) + .addCase(mountNotifications, (state) => { + state.mounted += 1; + updateLastReadId(state); + }) + .addCase(unmountNotifications, (state) => { + state.mounted -= 1; + }) + .addCase(focusApp, (state) => { + state.isTabVisible = true; + updateLastReadId(state); + }) + .addCase(unfocusApp, (state) => { + state.isTabVisible = false; + }) + .addMatcher( + isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess), + (state, action) => { + removeNotificationsForAccounts( + state, + [action.payload.id], + 'follow_request', + ); + }, + ) + .addMatcher( + isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), + (state) => { + state.isLoading = true; + }, + ) + .addMatcher( + isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected), + (state) => { + state.isLoading = false; + }, + ); + }, +); diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 226f2affe1030a..b3c138583c7f7c 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -12,6 +12,7 @@ import { import { fetchMarkers, } from '../actions/markers'; +import { clearNotifications } from '../actions/notification_groups'; import { NOTIFICATIONS_MOUNT, NOTIFICATIONS_UNMOUNT, @@ -21,7 +22,6 @@ import { NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_FILTER_SET, - NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, NOTIFICATIONS_LOAD_PENDING, NOTIFICATIONS_DELETE_MARKED_REQUEST, @@ -332,7 +332,7 @@ export default function notifications(state = initialState, action) { case authorizeFollowRequestSuccess.type: case rejectFollowRequestSuccess.type: return filterNotifications(state, [action.payload.id], 'follow_request'); - case NOTIFICATIONS_CLEAR: + case clearNotifications.pending.type: return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); case timelineDelete.type: return deleteByStatus(state, action.payload.statusId); diff --git a/app/javascript/flavours/glitch/selectors/notifications.ts b/app/javascript/flavours/glitch/selectors/notifications.ts new file mode 100644 index 00000000000000..7cc64aa6c584da --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/notifications.ts @@ -0,0 +1,34 @@ +import { createSelector } from '@reduxjs/toolkit'; + +import { compareId } from 'flavours/glitch/compare_id'; +import type { RootState } from 'flavours/glitch/store'; + +export const selectUnreadNotificationGroupsCount = createSelector( + [ + (s: RootState) => s.notificationGroups.lastReadId, + (s: RootState) => s.notificationGroups.pendingGroups, + (s: RootState) => s.notificationGroups.groups, + ], + (notificationMarker, pendingGroups, groups) => { + return ( + groups.filter( + (group) => + group.type !== 'gap' && + group.page_max_id && + compareId(group.page_max_id, notificationMarker) > 0, + ).length + + pendingGroups.filter( + (group) => + group.type !== 'gap' && + group.page_max_id && + compareId(group.page_max_id, notificationMarker) > 0, + ).length + ); + }, +); + +export const selectPendingNotificationGroupsCount = createSelector( + [(s: RootState) => s.notificationGroups.pendingGroups], + (pendingGroups) => + pendingGroups.filter((group) => group.type !== 'gap').length, +); diff --git a/app/javascript/flavours/glitch/selectors/settings.ts b/app/javascript/flavours/glitch/selectors/settings.ts new file mode 100644 index 00000000000000..ce2b8b15e59cb1 --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/settings.ts @@ -0,0 +1,40 @@ +import type { RootState } from 'flavours/glitch/store'; + +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +// state.settings is not yet typed, so we disable some ESLint checks for those selectors +export const selectSettingsNotificationsShows = (state: RootState) => + state.settings.getIn(['notifications', 'shows']).toJS() as Record< + string, + boolean + >; + +export const selectSettingsNotificationsExcludedTypes = (state: RootState) => + Object.entries(selectSettingsNotificationsShows(state)) + .filter(([_type, enabled]) => !enabled) + .map(([type, _enabled]) => type); + +export const selectSettingsNotificationsQuickFilterShow = (state: RootState) => + state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean; + +export const selectSettingsNotificationsQuickFilterActive = ( + state: RootState, +) => state.settings.getIn(['notifications', 'quickFilter', 'active']) as string; + +export const selectSettingsNotificationsQuickFilterAdvanced = ( + state: RootState, +) => + state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean; + +export const selectSettingsNotificationsShowUnread = (state: RootState) => + state.settings.getIn(['notifications', 'showUnread']) as boolean; + +export const selectNeedsNotificationPermission = (state: RootState) => + (state.settings.getIn(['notifications', 'alerts']).includes(true) && + state.notifications.get('browserSupport') && + state.notifications.get('browserPermission') === 'default' && + !state.settings.getIn([ + 'notifications', + 'dismissPermissionBanner', + ])) as boolean; + +/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 06225b61e6c254..cfa93cd924d7f4 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -1790,10 +1790,19 @@ body > [data-popper-placement] { } } -.status__wrapper-direct { +.status__wrapper-direct, +.notification-ungrouped--direct { background: rgba($ui-highlight-color, 0.05); - .status__prepend { + &:focus { + background: rgba($ui-highlight-color, 0.1); + } +} + +.status__wrapper-direct, +.notification-ungrouped--direct { + .status__prepend, + .notification-ungrouped__header { color: $highlight-text-color; } } @@ -2389,41 +2398,28 @@ a.account__display-name { } } -.notification__relationships-severance-event, -.notification__moderation-warning { - display: flex; - gap: 16px; +.notification-group--link { color: $secondary-text-color; text-decoration: none; - align-items: flex-start; - padding: 16px 32px; - border-bottom: 1px solid var(--background-border-color); - - &:hover { - color: $primary-text-color; - } - - .icon { - padding: 2px; - color: $highlight-text-color; - } - &__content { + .notification-group__main { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; flex-grow: 1; - font-size: 16px; - line-height: 24px; + font-size: 15px; + line-height: 22px; - strong { + strong, + bdi { font-weight: 700; } .link-button { font-size: inherit; line-height: inherit; + font-weight: inherit; } } } @@ -10747,8 +10743,8 @@ noscript { display: flex; align-items: center; border-bottom: 1px solid var(--background-border-color); - padding: 24px 32px; - gap: 16px; + padding: 16px 24px; + gap: 8px; color: $darker-text-color; text-decoration: none; @@ -10758,10 +10754,8 @@ noscript { color: $secondary-text-color; } - .icon { - width: 24px; - height: 24px; - padding: 2px; + .notification-group__icon { + color: inherit; } &__text { @@ -10899,6 +10893,251 @@ noscript { } } +.notification-group { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 16px 24px; + border-bottom: 1px solid var(--background-border-color); + + &__icon { + width: 40px; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: $dark-text-color; + + .icon { + width: 28px; + height: 28px; + } + } + + &--follow &__icon, + &--follow-request &__icon { + color: $highlight-text-color; + } + + &--favourite &__icon { + color: $gold-star; + } + + &--reblog &__icon { + color: $valid-value-color; + } + + &--relationships-severance-event &__icon, + &--admin-report &__icon, + &--admin-sign-up &__icon { + color: $dark-text-color; + } + + &--moderation-warning &__icon { + color: $red-bookmark; + } + + &--follow-request &__actions { + align-items: center; + display: flex; + gap: 8px; + + .icon-button { + border: 1px solid var(--background-border-color); + border-radius: 50%; + padding: 1px; + } + } + + &__main { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + overflow: hidden; + + &__header { + display: flex; + flex-direction: column; + gap: 8px; + + &__wrapper { + display: flex; + justify-content: space-between; + } + + &__label { + display: flex; + gap: 8px; + font-size: 15px; + line-height: 22px; + color: $darker-text-color; + + a { + color: inherit; + text-decoration: none; + } + + bdi { + font-weight: 700; + color: $primary-text-color; + } + + time { + color: $dark-text-color; + } + } + } + + &__status { + border: 1px solid var(--background-border-color); + border-radius: 8px; + padding: 8px; + } + } + + &__avatar-group { + display: flex; + gap: 8px; + height: 28px; + overflow-y: hidden; + flex-wrap: wrap; + } + + .status { + padding: 0; + border: 0; + } + + &__embedded-status { + &__account { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + color: $dark-text-color; + + bdi { + color: inherit; + } + } + + .account__avatar { + opacity: 0.5; + } + + &__content { + display: -webkit-box; + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + cursor: pointer; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + max-height: 4 * 22px; + overflow: hidden; + + p, + a { + color: inherit; + } + } + } +} + +.notification-ungrouped { + padding: 16px 24px; + border-bottom: 1px solid var(--background-border-color); + + &__header { + display: flex; + align-items: center; + gap: 8px; + color: $dark-text-color; + font-size: 15px; + line-height: 22px; + font-weight: 500; + padding-inline-start: 24px; + margin-bottom: 16px; + + &__icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + + .icon { + width: 16px; + height: 16px; + } + } + + a { + color: inherit; + text-decoration: none; + } + } + + .status { + border: 0; + padding: 0; + + &__avatar { + width: 40px; + height: 40px; + } + } + + .status__wrapper-direct { + background: transparent; + } + + $icon-margin: 48px; // 40px avatar + 8px gap + + .status__content, + .status__action-bar, + .media-gallery, + .video-player, + .audio-player, + .attachment-list, + .picture-in-picture-placeholder, + .more-from-author, + .status-card, + .hashtag-bar { + margin-inline-start: $icon-margin; + width: calc(100% - $icon-margin); + } + + .more-from-author { + width: calc(100% - $icon-margin + 2px); + } + + .status__content__read-more-button { + margin-inline-start: $icon-margin; + } + + .notification__report { + border: 0; + padding: 0; + } +} + +.notification-group--unread, +.notification-ungrouped--unread { + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + border-inline-start: 4px solid $highlight-text-color; + pointer-events: none; + } +} + .hover-card-controller[data-popper-reference-hidden='true'] { opacity: 0; pointer-events: none;