diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 47a875505d..114f20b48c 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -43,12 +43,29 @@ export type NotificationType = | 'admin.sign_up' | 'admin.report'; +export interface NotifyEmojiReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; + domain?: string; + width?: number; + height?: number; +} + +export interface NotificationEmojiReactionGroupJSON { + emoji_reaction: NotifyEmojiReactionJSON; + sample_account_ids: string[]; +} + export interface BaseNotificationJSON { id: string; type: NotificationType; created_at: string; group_key: string; account: ApiAccountJSON; + emoji_reaction?: NotifyEmojiReactionJSON; } export interface BaseNotificationGroupJSON { @@ -60,6 +77,7 @@ export interface BaseNotificationGroupJSON { most_recent_notification_id: string; page_min_id?: string; page_max_id?: string; + emoji_reaction_groups?: NotificationEmojiReactionGroupJSON[]; } interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { @@ -70,6 +88,7 @@ interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { interface NotificationWithStatusJSON extends BaseNotificationJSON { type: NotificationWithStatusType; status: ApiStatusJSON; + emoji_reaction?: NotifyEmojiReactionJSON; } interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { 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 61540d8cae..58f7b97161 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 @@ -32,6 +32,7 @@ export const NotificationEmojiReaction: React.FC<{ icon={EmojiReactionIcon} iconId='star' accountIds={notification.sampleAccountIds} + emojiReactionGroups={notification.emojiReactionGroups} statusId={notification.statusId} timestamp={notification.latest_page_notification_at} count={notification.notifications_count} 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 2af73c8362..d1d524042e 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 @@ -6,9 +6,11 @@ import { HotKeys } from 'react-hotkeys'; import { replyComposeById } from 'mastodon/actions/compose'; import { navigateToStatus } from 'mastodon/actions/statuses'; +import EmojiView from 'mastodon/components/emoji_view'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +import type { EmojiReactionGroup } from 'mastodon/models/notification_group'; import { useAppDispatch } from 'mastodon/store'; import { AvatarGroup } from './avatar_group'; @@ -26,6 +28,7 @@ export const NotificationGroupWithStatus: React.FC<{ actions?: JSX.Element; count: number; accountIds: string[]; + emojiReactionGroups?: EmojiReactionGroup[]; timestamp: string; labelRenderer: LabelRenderer; labelSeeMoreHref?: string; @@ -36,6 +39,7 @@ export const NotificationGroupWithStatus: React.FC<{ iconId, timestamp, accountIds, + emojiReactionGroups, actions, count, statusId, @@ -89,11 +93,28 @@ export const NotificationGroupWithStatus: React.FC<{
-
- + {emojiReactionGroups?.map((group) => ( +
+
+ + - {actions} -
+ {actions} +
+
+ ))} + + {!emojiReactionGroups && ( +
+ + + {actions} +
+ )}
{label} diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index 9810c8f00b..402b1f3729 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -6,6 +6,8 @@ import type { ApiNotificationJSON, NotificationType, NotificationWithStatusType, + NotificationEmojiReactionGroupJSON, + NotifyEmojiReactionJSON, } from 'mastodon/api_types/notifications'; import type { ApiReportJSON } from 'mastodon/api_types/reports'; @@ -22,6 +24,23 @@ interface BaseNotificationWithStatus extends BaseNotificationGroup { type: Type; statusId: string; + emojiReactionGroups?: EmojiReactionGroup[]; +} + +interface EmojiInfo { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; + domain?: string; + width?: number; + height?: number; +} + +export interface EmojiReactionGroup { + emoji: EmojiInfo; + sampleAccountIds: string[]; } interface BaseNotification @@ -119,6 +138,20 @@ function createAccountRelationshipSeveranceEventFromJSON( return eventJson; } +function createEmojiReactionGroupsFromJSON( + json: NotifyEmojiReactionJSON | undefined, + sampleAccountIds: string[], +): EmojiReactionGroup[] { + if (typeof json === 'undefined') return []; + + return [ + { + emoji: json, + sampleAccountIds, + }, + ]; +} + export function createNotificationGroupFromJSON( groupJson: ApiNotificationGroupJSON, ): NotificationGroup { @@ -126,7 +159,6 @@ export function createNotificationGroupFromJSON( switch (group.type) { case 'favourite': - case 'emoji_reaction': case 'reblog': case 'status': case 'mention': @@ -140,6 +172,29 @@ export function createNotificationGroupFromJSON( ...groupWithoutStatus, }; } + case 'emoji_reaction': { + const { + status_id: statusId, + emoji_reaction_groups: emojiReactionGroups, + ...groupWithoutStatus + } = group; + const groups = ( + typeof emojiReactionGroups === 'undefined' + ? ([] as NotificationEmojiReactionGroupJSON[]) + : emojiReactionGroups + ).map((g) => { + return { + sampleAccountIds: g.sample_account_ids, + emoji: g.emoji_reaction, + } as EmojiReactionGroup; + }); + return { + statusId, + sampleAccountIds, + emojiReactionGroups: groups, + ...groupWithoutStatus, + }; + } case 'admin.report': { const { report, ...groupWithoutTargetAccount } = group; return { @@ -187,7 +242,6 @@ export function createNotificationGroupFromNotificationJSON( switch (notification.type) { case 'favourite': - case 'emoji_reaction': case 'reblog': case 'status': case 'mention': @@ -195,6 +249,15 @@ export function createNotificationGroupFromNotificationJSON( case 'poll': case 'update': return { ...group, statusId: notification.status.id }; + case 'emoji_reaction': + return { + ...group, + statusId: notification.status.id, + emojiReactionGroups: createEmojiReactionGroupsFromJSON( + notification.emoji_reaction, + group.sampleAccountIds, + ), + }; case 'admin.report': return { ...group, report: createReportFromJSON(notification.report) }; case 'severed_relationships': diff --git a/app/javascript/mastodon/reducers/notification_groups.ts b/app/javascript/mastodon/reducers/notification_groups.ts index 0d3f34d2fb..b4262f92ee 100644 --- a/app/javascript/mastodon/reducers/notification_groups.ts +++ b/app/javascript/mastodon/reducers/notification_groups.ts @@ -211,6 +211,41 @@ function processNewNotification( if (existingGroupIndex > -1) { const existingGroup = groups[existingGroupIndex]; + if (existingGroup && existingGroup.type !== 'gap') { + // Update emoji reaction emoji groups + if (existingGroup.type === 'emoji_reaction') { + const emojiReactionGroups = existingGroup.emojiReactionGroups; + const emojiReactionData = notification.emoji_reaction; + + if (emojiReactionGroups && emojiReactionData) { + const sameEmojiIndex = emojiReactionGroups.findIndex( + (g) => g.emoji.name === emojiReactionData.name, + ); + + if (sameEmojiIndex > -1) { + const sameEmoji = emojiReactionGroups[sameEmojiIndex]; + + if (sameEmoji) { + if ( + !sameEmoji.sampleAccountIds.includes(notification.account.id) && + sameEmoji.sampleAccountIds.unshift(notification.account.id) > + NOTIFICATIONS_GROUP_MAX_AVATARS + ) + sameEmoji.sampleAccountIds.pop(); + + emojiReactionGroups.splice(sameEmojiIndex, 1); + emojiReactionGroups.unshift(sameEmoji); + } + } else { + emojiReactionGroups.unshift({ + emoji: emojiReactionData, + sampleAccountIds: [notification.account.id], + }); + } + } + } + } + if ( existingGroup && existingGroup.type !== 'gap' && diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ff2ec38958..5b948b87ee 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -10907,6 +10907,25 @@ noscript { &__wrapper { display: flex; justify-content: space-between; + + &__for_emoji_reaction { + display: flex; + justify-content: start; + + .emoji { + display: inline-block; + width: 40px; + align-self: center; + + img { + height: 20px; + } + } + + .notification-group__avatar-group { + margin-left: 8px; + } + } } &__label { diff --git a/app/models/notification_group.rb b/app/models/notification_group.rb index 223945f07b..a66f352fd3 100644 --- a/app/models/notification_group.rb +++ b/app/models/notification_group.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true class NotificationGroup < ActiveModelSerializers::Model - attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id + attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id, :emoji_reaction_groups # Try to keep this consistent with `app/javascript/mastodon/models/notification_group.ts` SAMPLE_ACCOUNTS_SIZE = 8 + SAMPLE_ACCOUNTS_SIZE_FOR_EMOJI_REACTION = 40 + + class NotificationEmojiReactionGroup < ActiveModelSerializers::Model + attributes :emoji_reaction, :sample_accounts + end def self.from_notification(notification, max_id: nil) if notification.group_key.present? @@ -16,10 +21,14 @@ class NotificationGroup < ActiveModelSerializers::Model most_recent_notifications = scope.order(id: :desc).includes(:from_account).take(SAMPLE_ACCOUNTS_SIZE) most_recent_id = most_recent_notifications.first.id sample_accounts = most_recent_notifications.map(&:from_account) + emoji_reaction_groups = extract_emoji_reaction_pair( + scope.order(id: :desc).includes(emoji_reaction: :account).take(SAMPLE_ACCOUNTS_SIZE_FOR_EMOJI_REACTION) + ) notifications_count = scope.count else most_recent_id = notification.id sample_accounts = [notification.from_account] + emoji_reaction_groups = extract_emoji_reaction_pair([notification]) notifications_count = 1 end @@ -27,6 +36,7 @@ class NotificationGroup < ActiveModelSerializers::Model notification: notification, group_key: notification.group_key || "ungrouped-#{notification.id}", sample_accounts: sample_accounts, + emoji_reaction_groups: emoji_reaction_groups, notifications_count: notifications_count, most_recent_notification_id: most_recent_id ) @@ -38,4 +48,16 @@ class NotificationGroup < ActiveModelSerializers::Model :account_relationship_severance_event, :account_warning, to: :notification, prefix: false + + def self.extract_emoji_reaction_pair(scope) + scope = scope.filter { |g| g.emoji_reaction.present? } + + return [] if scope.empty? + return [] unless scope.first.type == :emoji_reaction + + scope + .each_with_object({}) { |e, h| h[e.emoji_reaction.name] = (h[e.emoji_reaction.name] || []).push(e.emoji_reaction) } + .to_a + .map { |pair| NotificationEmojiReactionGroup.new(emoji_reaction: pair[1].first, sample_accounts: pair[1].take(SAMPLE_ACCOUNTS_SIZE).map(&:account)) } + end end diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb index 3e4faec0eb..e10dfded88 100644 --- a/app/serializers/rest/notification_group_serializer.rb +++ b/app/serializers/rest/notification_group_serializer.rb @@ -30,6 +30,10 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer object.type == :'admin.report' end + def emoji_reaction_type? + object.type == :emoji_reaction + end + def relationship_severance_event? object.type == :severed_relationships end @@ -56,4 +60,15 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer def paginated? !instance_options[:group_metadata].nil? end + + class NotificationEmojiReactionGroupSerializer < ActiveModel::Serializer + has_one :emoji_reaction, serializer: REST::NotifyEmojiReactionSerializer + attribute :sample_account_ids + + def sample_account_ids + object.sample_accounts.pluck(:id).map(&:to_s) + end + end + + has_many :emoji_reaction_groups, each_serializer: NotificationEmojiReactionGroupSerializer, if: :emoji_reaction_type? end diff --git a/app/serializers/rest/notify_emoji_reaction_serializer.rb b/app/serializers/rest/notify_emoji_reaction_serializer.rb index 6f2b99a0c8..f2096c85b0 100644 --- a/app/serializers/rest/notify_emoji_reaction_serializer.rb +++ b/app/serializers/rest/notify_emoji_reaction_serializer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class REST::NotifyEmojiReactionSerializer < ActiveModel::Serializer + # Please update app/javascript/api_types/notification.ts when making changes to the attributes include RoutingHelper attributes :name diff --git a/spec/models/notification_group_spec.rb b/spec/models/notification_group_spec.rb new file mode 100644 index 0000000000..f594010945 --- /dev/null +++ b/spec/models/notification_group_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NotificationGroup do + subject { described_class.from_notification(notification) } + + context 'when emoji reaction notifications' do + let(:target_status) { Fabricate(:status) } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:ohagi) { Fabricate(:account) } + let(:custom_emoji) { Fabricate(:custom_emoji) } + let(:emoji_reaction) { Fabricate(:emoji_reaction, account: alice, status: target_status, name: custom_emoji.shortcode, custom_emoji: custom_emoji) } + let(:notification) { Fabricate(:notification, account: target_status.account, activity: emoji_reaction, group_key: group_key) } + let(:group_key) { 5 } + + it 'with single emoji_reaction' do + group = subject.emoji_reaction_groups.first + + expect(group).to_not be_nil + expect(group.emoji_reaction.id).to eq emoji_reaction.id + expect(group.sample_accounts.map(&:id)).to contain_exactly(alice.id) + end + + it 'with multiple reactions' do + second = Fabricate(:emoji_reaction, account: bob, status: target_status, name: custom_emoji.shortcode, custom_emoji: custom_emoji) + Fabricate(:notification, account: target_status.account, activity: second, group_key: group_key) + + group = subject.emoji_reaction_groups.first + + expect(group).to_not be_nil + expect([emoji_reaction.id, second.id]).to include group.emoji_reaction.id + expect(group.sample_accounts.map(&:id)).to contain_exactly(alice.id, bob.id) + end + + it 'with multiple reactions and multiple emojis' do + second = Fabricate(:emoji_reaction, account: bob, status: target_status, name: custom_emoji.shortcode, custom_emoji: custom_emoji) + Fabricate(:notification, account: target_status.account, activity: second, group_key: group_key) + third = Fabricate(:emoji_reaction, account: ohagi, status: target_status, name: '😀') + Fabricate(:notification, account: target_status.account, activity: third, group_key: group_key) + + group = subject.emoji_reaction_groups.find { |g| g.emoji_reaction.name == custom_emoji.shortcode } + second_group = subject.emoji_reaction_groups.find { |g| g.emoji_reaction.name == '😀' } + + expect(group).to_not be_nil + expect([emoji_reaction.id, second.id]).to include group.emoji_reaction.id + expect(group.sample_accounts.map(&:id)).to contain_exactly(alice.id, bob.id) + + expect(second_group).to_not be_nil + expect(third.id).to eq second_group.emoji_reaction.id + expect(second_group.sample_accounts.map(&:id)).to contain_exactly(ohagi.id) + end + end +end