diff --git a/app/helpers/kmyblue_capabilities_helper.rb b/app/helpers/kmyblue_capabilities_helper.rb index 075869ee60..7471ca2343 100644 --- a/app/helpers/kmyblue_capabilities_helper.rb +++ b/app/helpers/kmyblue_capabilities_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module KmyblueCapabilitiesHelper + KMYBLUE_API_VERSION = 1 + def fedibird_capabilities capabilities = %i( enable_wide_emoji diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index e443ceb325..1392d3a667 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -746,12 +746,12 @@ export function expandMentionedUsersFail(id, error) { }; } -function toggleReblogWithoutConfirmation(status, privacy) { +function toggleReblogWithoutConfirmation(status, visibility) { return (dispatch) => { if (status.get('reblogged')) { dispatch(unreblog({ statusId: status.get('id') })); } else { - dispatch(reblog({ statusId: status.get('id'), privacy })); + dispatch(reblog({ statusId: status.get('id'), visibility })); } }; } diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 6b699706e2..51f83f1d24 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -11,6 +11,7 @@ import type { } from 'mastodon/api_types/notifications'; import { allNotificationTypes } from 'mastodon/api_types/notifications'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import { usePendingItems } from 'mastodon/initial_state'; import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import { selectSettingsNotificationsExcludedTypes, @@ -103,6 +104,28 @@ export const fetchNotificationsGap = createDataLoadingThunk( }, ); +export const pollRecentNotifications = createDataLoadingThunk( + 'notificationGroups/pollRecentNotifications', + async (_params, { getState }) => { + return apiFetchNotifications({ + max_id: undefined, + // In slow mode, we don't want to include notifications that duplicate the already-displayed ones + since_id: usePendingItems + ? getState().notificationGroups.groups.find( + (group) => group.type !== 'gap', + )?.page_max_id + : undefined, + }); + }, + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + export const processNewNotificationForGroups = createAppAsyncThunk( 'notificationGroups/processNew', (notification: ApiNotificationJSON, { dispatch, getState }) => { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index bed251e75f..78b4972c77 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,7 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { processNewNotificationForGroups, refreshStaleNotificationGroups } from './notification_groups'; +import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications'; import { updateStatus } from './statuses'; import { @@ -38,7 +38,7 @@ const randomUpTo = max => * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function): Promise} [options.fallback] + * @param {function(Function, Function): Promise} [options.fallback] * @param {function(): void} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} @@ -53,11 +53,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti let pollingId; /** - * @param {function(Function): Promise} fallback + * @param {function(Function, Function): Promise} fallback */ const useFallback = async fallback => { - await fallback(dispatch); + await fallback(dispatch, getState); // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); }; @@ -144,10 +144,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti /** * @param {Function} dispatch + * @param {Function} getState */ -async function refreshHomeTimelineAndNotification(dispatch) { +async function refreshHomeTimelineAndNotification(dispatch, getState) { await dispatch(expandHomeTimeline({ maxId: undefined })); - await dispatch(expandNotifications({})); + + // TODO: remove this once the groups feature replaces the previous one + if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + // TODO: polling for merged notifications + try { + await dispatch(pollRecentGroupNotifications()); + } catch (error) { + // TODO + } + } else { + await dispatch(expandNotifications({})); + } + await dispatch(fetchAnnouncements()); } diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts index 753a11d465..88e9820dd8 100644 --- a/app/javascript/mastodon/api/notifications.ts +++ b/app/javascript/mastodon/api/notifications.ts @@ -19,6 +19,7 @@ const exceptInvalidNotifications = ( export const apiFetchNotifications = async (params?: { exclude_types?: string[]; max_id?: string; + since_id?: string; }) => { const response = await api().request({ method: 'GET', diff --git a/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx b/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx new file mode 100644 index 0000000000..82ecb93ee5 --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/components/displayed_name.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; + +import { useAppSelector } from 'mastodon/store'; + +export const DisplayedName: React.FC<{ + accountIds: string[]; +}> = ({ accountIds }) => { + const lastAccountId = accountIds[0] ?? '0'; + const account = useAppSelector((state) => state.accounts.get(lastAccountId)); + + if (!account) return null; + + return ( + + + + ); +}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx b/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx deleted file mode 100644 index 3d70cc0b62..0000000000 --- a/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { useAppSelector } from 'mastodon/store'; - -export const NamesList: React.FC<{ - accountIds: string[]; - total: number; - seeMoreHref?: string; -}> = ({ accountIds, total, seeMoreHref }) => { - const lastAccountId = accountIds[0] ?? '0'; - const account = useAppSelector((state) => state.accounts.get(lastAccountId)); - - if (!account) return null; - - const displayedName = ( - - - - ); - - if (total === 1) { - return displayedName; - } - - if (seeMoreHref) - return ( - {chunks}, - }} - /> - ); - - return ( - - ); -}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx index 9f7afc63f5..73a5851ad3 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx @@ -6,13 +6,27 @@ import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_ import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total) => { + if (total === 1) + return ( + + ); + + return ( + + ); +}; export const NotificationAdminSignUp: React.FC<{ notification: NotificationGroupAdminSignUp; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_emoji_reaction.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_emoji_reaction.tsx index 58f7b97161..c0f09fd67e 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_emoji_reaction.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_emoji_reaction.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react'; import type { NotificationGroupEmojiReaction } from 'mastodon/models/notification_group'; import { useAppSelector } from 'mastodon/store'; @@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => { + if (total === 1) + return ( + + ); + + return ( + + seeMoreHref ? {chunks} : chunks, + }} + /> + ); +}; export const NotificationEmojiReaction: React.FC<{ notification: NotificationGroupEmojiReaction; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx index 22838fe69b..eba37fe937 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import type { NotificationGroupFavourite } from 'mastodon/models/notification_group'; import { useAppSelector } from 'mastodon/store'; @@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => { + if (total === 1) + return ( + + ); + + return ( + + seeMoreHref ? {chunks} : chunks, + }} + /> + ); +}; export const NotificationFavourite: React.FC<{ notification: NotificationGroupFavourite; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx index 3760096d44..6a9a45d242 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx @@ -10,13 +10,27 @@ import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total) => { + if (total === 1) + return ( + + ); + + return ( + + ); +}; const FollowerCount: React.FC<{ accountId: string }> = ({ accountId }) => { const account = useAppSelector((s) => s.accounts.get(accountId)); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx index 281bfd94af..5f61f2cd78 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx @@ -21,13 +21,27 @@ const messages = defineMessages({ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, }); -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total) => { + if (total === 1) + return ( + + ); + + return ( + + ); +}; export const NotificationFollowRequest: React.FC<{ notification: NotificationGroupFollowRequest; 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 a739561492..34749bf295 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 @@ -14,11 +14,13 @@ import type { EmojiReactionGroup } from 'mastodon/models/notification_group'; import { useAppDispatch } from 'mastodon/store'; import { AvatarGroup } from './avatar_group'; +import { DisplayedName } from './displayed_name'; import { EmbeddedStatus } from './embedded_status'; -import { NamesList } from './names_list'; export type LabelRenderer = ( - values: Record, + displayedName: JSX.Element, + total: number, + seeMoreHref?: string, ) => JSX.Element; export const NotificationGroupWithStatus: React.FC<{ @@ -54,15 +56,11 @@ export const NotificationGroupWithStatus: React.FC<{ const label = useMemo( () => - labelRenderer({ - name: ( - - ), - }), + labelRenderer( + , + count, + labelSeeMoreHref, + ), [labelRenderer, accountIds, count, labelSeeMoreHref], ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_list_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_list_status.tsx index 60230874f6..dececeee14 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_list_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_list_status.tsx @@ -11,10 +11,12 @@ import { NotificationWithStatus } from './notification_with_status'; const createLabelRenderer = ( notification: NotificationGroupListStatus, ): LabelRenderer => { - const renderer: LabelRenderer = (values) => { + const renderer: LabelRenderer = (displayedName) => { const list = notification.list; + let listHref: JSX.Element | undefined; + if (list) { - const listLink = ( + listHref = ( ); - values.listName = listLink; } return ( ); }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx index 0625568688..7b3bda85e5 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import type { NotificationGroupReblog } from 'mastodon/models/notification_group'; import { useAppSelector } from 'mastodon/store'; @@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (values) => ( - -); +const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => { + if (total === 1) + return ( + + ); + + return ( + + seeMoreHref ? {chunks} : chunks, + }} + /> + ); +}; export const NotificationReblog: React.FC<{ notification: NotificationGroupReblog; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx index 9ade355a71..2955c3aeac 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx @@ -6,11 +6,11 @@ import type { NotificationGroupStatus } from 'mastodon/models/notification_group import type { LabelRenderer } from './notification_group_with_status'; import { NotificationWithStatus } from './notification_with_status'; -const labelRenderer: LabelRenderer = (values) => ( +const labelRenderer: LabelRenderer = (displayedName) => ( ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_status_reference.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_status_reference.tsx index 683d3bcc44..49674f42aa 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_status_reference.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_status_reference.tsx @@ -6,11 +6,11 @@ import type { NotificationGroupStatusReference } from 'mastodon/models/notificat import type { LabelRenderer } from './notification_group_with_status'; import { NotificationWithStatus } from './notification_with_status'; -const labelRenderer: LabelRenderer = (values) => ( +const labelRenderer: LabelRenderer = (displayedName) => ( ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx index c518367bf5..731f319f89 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx @@ -6,11 +6,11 @@ import type { NotificationGroupUpdate } from 'mastodon/models/notification_group import type { LabelRenderer } from './notification_group_with_status'; import { NotificationWithStatus } from './notification_with_status'; -const labelRenderer: LabelRenderer = (values) => ( +const labelRenderer: LabelRenderer = (displayedName) => ( ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index d8c55183bf..6541227355 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -15,7 +15,7 @@ import { Icon } from 'mastodon/components/icon'; import Status from 'mastodon/containers/status_container'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; -import { NamesList } from './names_list'; +import { DisplayedName } from './displayed_name'; import type { LabelRenderer } from './notification_group_with_status'; export const NotificationWithStatus: React.FC<{ @@ -42,10 +42,7 @@ export const NotificationWithStatus: React.FC<{ const dispatch = useAppDispatch(); const label = useMemo( - () => - labelRenderer({ - name: , - }), + () => labelRenderer(, count), [labelRenderer, accountIds, count], ); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 5283927beb..5a1ceb98f2 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -572,8 +572,6 @@ "mute_modal.title": "Mute user?", "mute_modal.you_wont_see_mentions": "You won't see posts that mention them.", "mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.", - "name_and_others": "{name} and {count, plural, one {# other} other {# others}}", - "name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}}", "navigation_bar.about": "About", "navigation_bar.advanced_interface": "Open in advanced web interface", "navigation_bar.antennas": "Antenna", @@ -612,10 +610,15 @@ "notification.admin.report_statuses": "{name} reported {target} for {category}", "notification.admin.report_statuses_other": "{name} reported {target}", "notification.admin.sign_up": "{name} signed up", - "notification.emoji_reaction": "{name} reacted your status with emoji", + "notification.admin.sign_up.name_and_others": "{name} and {count, plural, one {# other} other {# others}} signed up", + "notification.emoji_reaction": "{name} reacted your post with emoji", + "notification.emoji_reaction.name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}} reacted your post with emoji", "notification.favourite": "{name} favorited your post", + "notification.favourite.name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}} favorited your post", "notification.follow": "{name} followed you", + "notification.follow.name_and_others": "{name} and {count, plural, one {# other} other {# others}} followed you", "notification.follow_request": "{name} has requested to follow you", + "notification.follow_request.name_and_others": "{name} and {count, plural, one {# other} other {# others}} has requested to follow you", "notification.label.mention": "Mention", "notification.label.private_mention": "Private mention", "notification.label.private_reply": "Private reply", @@ -635,6 +638,7 @@ "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you voted in has ended", "notification.reblog": "{name} boosted your post", + "notification.reblog.name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}} boosted your post", "notification.relationships_severance_event": "Lost connections with {name}", "notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.", "notification.relationships_severance_event.domain_block": "An admin from {from} has blocked {target}, including {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 22f0c8b70d..45c0be48f4 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -577,6 +577,7 @@ "notification.admin.report_statuses_other": "{name}さんが{target}さんを通報しました", "notification.admin.sign_up": "{name}さんがサインアップしました", "notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました", + "notification.emoji_reaction.name_and_others_with_link": "{name}さんと{count, plural, other {他#名}}があなたの投稿に絵文字をつけました", "notification.favourite": "{name}さんがお気に入りしました", "notification.follow": "{name}さんにフォローされました", "notification.follow_request": "{name}さんがあなたにフォローリクエストしました", diff --git a/app/javascript/mastodon/reducers/notification_groups.ts b/app/javascript/mastodon/reducers/notification_groups.ts index c927f72015..ea9b5c6319 100644 --- a/app/javascript/mastodon/reducers/notification_groups.ts +++ b/app/javascript/mastodon/reducers/notification_groups.ts @@ -20,12 +20,16 @@ import { mountNotifications, unmountNotifications, refreshStaleNotificationGroups, + pollRecentNotifications, } from 'mastodon/actions/notification_groups'; import { disconnectTimeline, timelineDelete, } from 'mastodon/actions/timelines_typed'; -import type { ApiNotificationJSON } from 'mastodon/api_types/notifications'; +import type { + ApiNotificationJSON, + ApiNotificationGroupJSON, +} from 'mastodon/api_types/notifications'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems } from 'mastodon/initial_state'; import { @@ -331,6 +335,106 @@ function commitLastReadId(state: NotificationGroupsState) { } } +function fillNotificationsGap( + groups: NotificationGroupsState['groups'], + gap: NotificationGap, + notifications: ApiNotificationGroupJSON[], +): NotificationGroupsState['groups'] { + // find the gap in the existing notifications + const gapIndex = groups.findIndex( + (groupOrGap) => + groupOrGap.type === 'gap' && + groupOrGap.sinceId === gap.sinceId && + groupOrGap.maxId === gap.maxId, + ); + + if (gapIndex < 0) + // We do not know where to insert, let's return + return groups; + + // 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 = 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 = 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 + groups = groups.filter( + (groupOrGap) => + groupOrGap.type !== 'gap' && !apiGroupKeys.includes(groupOrGap.group_key), + ); + + // Replace the gap with API results (+ the new gap if needed) + groups.splice(gapIndex, 1, ...toInsert); + + // Finally, merge any adjacent gaps that could have been created by filtering + // groups earlier + mergeGaps(groups); + + return groups; +} + +// Ensure the groups list starts with a gap, mutating it to prepend one if needed +function ensureLeadingGap( + groups: NotificationGroupsState['groups'], +): NotificationGap { + if (groups[0]?.type === 'gap') { + // We're expecting new notifications, so discard the maxId if there is one + groups[0].maxId = undefined; + + return groups[0]; + } else { + const gap: NotificationGap = { + type: 'gap', + sinceId: groups[0]?.page_min_id, + }; + + groups.unshift(gap); + return gap; + } +} + export const notificationGroupsReducer = createReducer( initialState, (builder) => { @@ -344,86 +448,36 @@ export const notificationGroupsReducer = createReducer( 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, + state.groups = fillNotificationsGap( + state.groups, + action.meta.arg.gap, + action.payload.notifications, ); + state.isLoading = false; - 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), + updateLastReadId(state); + }) + .addCase(pollRecentNotifications.fulfilled, (state, action) => { + if (usePendingItems) { + const gap = ensureLeadingGap(state.pendingGroups); + state.pendingGroups = fillNotificationsGap( + state.pendingGroups, + gap, + action.payload.notifications, + ); + } else { + const gap = ensureLeadingGap(state.groups); + state.groups = fillNotificationsGap( + state.groups, + gap, + action.payload.notifications, ); - - 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); + trimNotifications(state); }) .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { const notification = action.payload; @@ -438,10 +492,11 @@ export const notificationGroupsReducer = createReducer( }) .addCase(disconnectTimeline, (state, action) => { if (action.payload.timeline === 'home') { - if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') { - state.groups.unshift({ + const groups = usePendingItems ? state.pendingGroups : state.groups; + if (groups.length > 0 && groups[0]?.type !== 'gap') { + groups.unshift({ type: 'gap', - sinceId: state.groups[0]?.page_min_id, + sinceId: groups[0]?.page_min_id, }); } } @@ -488,12 +543,13 @@ export const notificationGroupsReducer = createReducer( } } } - trimNotifications(state); }); // Then build the consolidated list and clear pending groups state.groups = state.pendingGroups.concat(state.groups); state.pendingGroups = []; + mergeGaps(state.groups); + trimNotifications(state); }) .addCase(updateScrollPosition.fulfilled, (state, action) => { state.scrolledToTop = action.payload.top; @@ -553,13 +609,21 @@ export const notificationGroupsReducer = createReducer( }, ) .addMatcher( - isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), + isAnyOf( + fetchNotifications.pending, + fetchNotificationsGap.pending, + pollRecentNotifications.pending, + ), (state) => { state.isLoading = true; }, ) .addMatcher( - isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected), + isAnyOf( + fetchNotifications.rejected, + fetchNotificationsGap.rejected, + pollRecentNotifications.rejected, + ), (state) => { state.isLoading = false; }, diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 8d801e4cd5..1df556b42a 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -414,9 +414,6 @@ border-color: transparent transparent $white; } -.hero-widget, -.moved-account-widget, -.memoriam-widget, .activity-stream, .nothing-here, .directory__tag > a, diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 852f1a33e1..89778b9f3b 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -130,21 +130,11 @@ .older { float: left; padding-inline-start: 0; - - .fa { - display: inline-block; - margin-inline-end: 5px; - } } .newer { float: right; padding-inline-end: 0; - - .fa { - display: inline-block; - margin-inline-start: 5px; - } } .disabled { diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index bcdad6a964..ae93829c0c 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -122,10 +122,6 @@ $content-width: 840px; overflow: hidden; text-overflow: ellipsis; - i.fa { - margin-inline-end: 5px; - } - &:hover { color: $primary-text-color; transition: all 100ms linear; @@ -306,10 +302,6 @@ $content-width: 840px; box-shadow: none; } - .directory__tag .table-action-link .fa { - color: inherit; - } - .directory__tag h4 { font-size: 18px; font-weight: 700; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 421c5e9991..733ad28e9c 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2981,10 +2981,6 @@ $ui-header-logo-wordmark-width: 99px; padding-inline-end: 30px; } - .search__icon .fa { - top: 15px; - } - .scrollable { overflow: visible; @@ -3520,26 +3516,6 @@ $ui-header-logo-wordmark-width: 99px; height: calc(100% - 10px); overflow-y: hidden; - .hero-widget { - box-shadow: none; - - &__text, - &__img, - &__img img { - border-radius: 0; - } - - &__text { - padding: 15px; - color: $secondary-text-color; - - strong { - font-weight: 700; - color: $primary-text-color; - } - } - } - .compose-form { flex: 1 1 auto; min-height: 0; @@ -6529,7 +6505,7 @@ a.status-card { } .boost-modal__container { - overflow-x: scroll; + overflow-y: auto; padding: 10px; .status { diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index 12d0a6b92f..1621220ccb 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -113,10 +113,6 @@ flex: 1 1 auto; } - .fa { - flex: 0 0 auto; - } - strong { font-weight: 700; } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 722c90ca45..5c17f97cef 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -942,10 +942,6 @@ code { font-weight: 700; } } - - .fa { - font-weight: 400; - } } } } diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss index 07fe96fc3a..e4e299ff82 100644 --- a/app/javascript/styles/mastodon/rtl.scss +++ b/app/javascript/styles/mastodon/rtl.scss @@ -41,14 +41,6 @@ body.rtl { no-repeat left 8px center / auto 16px; } - .fa-chevron-left::before { - content: '\F054'; - } - - .fa-chevron-right::before { - content: '\F053'; - } - .dismissable-banner, .warning-banner { &__action { diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index cecfb7808b..24a176c2cc 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -142,11 +142,6 @@ a.table-action-link { color: $highlight-text-color; } - i.fa { - font-weight: 400; - margin-inline-end: 5px; - } - &:first-child { padding-inline-start: 0; } diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index 7d4adc1d6f..b37d790ce3 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -1,295 +1,3 @@ -@use 'sass:math'; - -.hero-widget { - margin-bottom: 10px; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - - &:last-child { - margin-bottom: 0; - } - - &__img { - width: 100%; - position: relative; - overflow: hidden; - border-radius: 4px 4px 0 0; - background: $base-shadow-color; - - img { - object-fit: cover; - display: block; - width: 100%; - height: 100%; - margin: 0; - border-radius: 4px 4px 0 0; - } - } - - &__text { - background: $ui-base-color; - padding: 20px; - border-radius: 0 0 4px 4px; - font-size: 15px; - color: $darker-text-color; - line-height: 20px; - word-wrap: break-word; - font-weight: 400; - - .emojione { - min-width: 20px; - max-width: min(10em, 100%); - height: 20px; - margin: -3px 0 0; - margin-inline-start: 0.075em; - margin-inline-end: 0.075em; - } - - p { - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } - } - - em { - display: inline; - margin: 0; - padding: 0; - font-weight: 700; - background: transparent; - font-family: inherit; - font-size: inherit; - line-height: inherit; - color: lighten($darker-text-color, 10%); - } - - a { - color: $secondary-text-color; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - - @media screen and (max-width: $no-gap-breakpoint) { - display: none; - } -} - -.endorsements-widget { - margin-bottom: 10px; - padding-bottom: 10px; - - h4 { - padding: 10px; - text-transform: uppercase; - font-weight: 700; - font-size: 13px; - color: $darker-text-color; - } - - .account { - padding: 10px 0; - - &:last-child { - border-bottom: 0; - } - - .account__display-name { - display: flex; - align-items: center; - } - } - - .trends__item { - padding: 10px; - } -} - -.trends-widget { - h4 { - color: $darker-text-color; - } -} - -.placeholder-widget { - padding: 16px; - border-radius: 4px; - border: 2px dashed $dark-text-color; - text-align: center; - color: $darker-text-color; - margin-bottom: 10px; -} - -.moved-account-widget { - padding: 15px; - padding-bottom: 20px; - border-radius: 4px; - background: $ui-base-color; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - color: $secondary-text-color; - font-weight: 400; - margin-bottom: 10px; - - strong, - a { - font-weight: 500; - - @each $lang in $cjk-langs { - &:lang(#{$lang}) { - font-weight: 700; - } - } - } - - a { - color: inherit; - text-decoration: underline; - - &.mention { - text-decoration: none; - - span { - text-decoration: none; - } - - &:focus, - &:hover, - &:active { - text-decoration: none; - - span { - text-decoration: underline; - } - } - } - } - - &__message { - margin-bottom: 15px; - - .fa { - margin-inline-end: 5px; - color: $darker-text-color; - } - } - - &__card { - .detailed-status__display-avatar { - position: relative; - cursor: pointer; - } - - .detailed-status__display-name { - margin-bottom: 0; - text-decoration: none; - - span { - font-weight: 400; - } - } - } -} - -.memoriam-widget { - padding: 20px; - border-radius: 4px; - background: $base-shadow-color; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - font-size: 14px; - color: $darker-text-color; - margin-bottom: 10px; -} - -.directory { - background: var(--background-color); - border-radius: 4px; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - - &__tag { - box-sizing: border-box; - margin-bottom: 10px; - - & > a, - & > div { - display: flex; - align-items: center; - justify-content: space-between; - border: 1px solid var(--background-border-color); - border-radius: 4px; - padding: 15px; - text-decoration: none; - color: inherit; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - } - - & > a { - &:hover, - &:active, - &:focus { - background: $ui-base-color; - } - } - - &.active > a { - background: $ui-highlight-color; - cursor: default; - } - - &.disabled > div { - opacity: 0.5; - cursor: default; - } - - h4 { - flex: 1 1 auto; - font-size: 18px; - font-weight: 700; - color: $primary-text-color; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - .fa { - color: $darker-text-color; - } - - small { - display: block; - font-weight: 400; - font-size: 15px; - margin-top: 8px; - color: $darker-text-color; - } - } - - &.active h4 { - &, - .fa, - small, - .trends__item__current { - color: $primary-text-color; - } - } - - .avatar-stack { - flex: 0 0 auto; - width: (36px + 4px) * 3; - } - - &.active .avatar-stack .account__avatar { - border-color: $ui-highlight-color; - } - - .trends__item__current { - padding-inline-end: 0; - } - } -} - .accounts-table { width: 100%; @@ -367,9 +75,7 @@ padding-inline-end: 16px; } - .fa { - font-size: 16px; - + .icon { &.active { color: $highlight-text-color; } @@ -389,27 +95,3 @@ } } } - -.moved-account-widget, -.memoriam-widget, -.directory { - @media screen and (max-width: $no-gap-breakpoint) { - margin-bottom: 0; - box-shadow: none; - border-radius: 0; - } -} - -.placeholder-widget { - a { - text-decoration: none; - font-weight: 500; - color: $ui-highlight-color; - - &:hover, - &:focus, - &:active { - text-decoration: underline; - } - } -} diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index ea4c6ec8c0..a9e0309af2 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -13,7 +13,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :domain, :title, :version, :source_url, :description, :usage, :thumbnail, :languages, :configuration, - :registrations, :fedibird_capabilities + :registrations, :fedibird_capabilities, :api_versions has_one :contact, serializer: ContactSerializer has_many :rules, serializer: REST::RuleSerializer @@ -119,6 +119,13 @@ class REST::InstanceSerializer < ActiveModel::Serializer } end + def api_versions + { + mastodon: 1, + kmyblue: KMYBLUE_API_VERSION, + } + end + private def registrations_enabled? diff --git a/app/views/statuses/_poll.html.haml b/app/views/statuses/_poll.html.haml index cf01b1da01..62416a44da 100644 --- a/app/views/statuses/_poll.html.haml +++ b/app/views/statuses/_poll.html.haml @@ -1,11 +1,10 @@ :ruby show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired? - own_votes = user_signed_in? ? poll.own_votes(current_account) : [] total_votes_count = poll.voters_count || poll.votes_count .poll %ul - - poll.loaded_options.each_with_index do |option, index| + - poll.loaded_options.each do |option| %li - if show_results - percent = total_votes_count.positive? ? 100 * option.votes_count / total_votes_count : 0 @@ -14,9 +13,6 @@ #{percent.round}% %span.poll__option__text = prerender_custom_emojis(h(option.title), status.emojis) - - if own_votes.include?(index) - %span.poll__voted - %i.poll__voted__mark.fa.fa-check %progress{ max: 100, value: [percent, 1].max, 'aria-hidden': 'true' } %span.poll__chart diff --git a/spec/requests/api/v2/instance_spec.rb b/spec/requests/api/v2/instance_spec.rb index 5c464f09a7..2636970d6e 100644 --- a/spec/requests/api/v2/instance_spec.rb +++ b/spec/requests/api/v2/instance_spec.rb @@ -18,6 +18,7 @@ describe 'Instances' do expect(body_as_json) .to be_present .and include(title: 'Mastodon') + .and include_api_versions .and include_configuration_limits end end @@ -32,6 +33,7 @@ describe 'Instances' do expect(body_as_json) .to be_present .and include(title: 'Mastodon') + .and include_api_versions .and include_configuration_limits end end @@ -53,5 +55,13 @@ describe 'Instances' do ) ) end + + def include_api_versions + include( + api_versions: include( + mastodon: anything + ) + ) + end end end