-
-
+ {emojiReactionGroups?.map((group) => (
+
+
+ ))}
+
+ {!emojiReactionGroups && (
+
+ )}
{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