Change: 絵文字リアクションの通知のグループ化で、アカウントを絵文字の種類ごとに表示 (#796)

* Change: 絵文字リアクションの通知のグループ化で、アカウントを絵文字の種類ごとに表示

* Fix lint

* アカウントの一括取得数を制限

* ストリーミング対応

* Fix

* Fix

* Fix

* Fix some problems

* Fix
This commit is contained in:
KMY(雪あすか) 2024-08-17 08:16:27 +09:00 committed by GitHub
parent 5dec110dec
commit f14c2d3ada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 258 additions and 7 deletions

View file

@ -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 {

View file

@ -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}

View file

@ -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<{
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__wrapper'>
<AvatarGroup accountIds={accountIds} />
{emojiReactionGroups?.map((group) => (
<div key={group.emoji.name}>
<div className='notification-group__main__header__wrapper__for_emoji_reaction'>
<EmojiView
name={group.emoji.name}
url={group.emoji.url}
staticUrl={group.emoji.static_url}
/>
<AvatarGroup accountIds={group.sampleAccountIds} />
{actions}
</div>
{actions}
</div>
</div>
))}
{!emojiReactionGroups && (
<div className='notification-group__main__header__wrapper'>
<AvatarGroup accountIds={accountIds} />
{actions}
</div>
)}
<div className='notification-group__main__header__label'>
{label}

View file

@ -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<Type extends NotificationWithStatusType>
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<Type extends NotificationType>
@ -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':

View file

@ -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' &&

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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