Change: 絵文字リアクションの通知のグループ化で、アカウントを絵文字の種類ごとに表示 (#796)
* Change: 絵文字リアクションの通知のグループ化で、アカウントを絵文字の種類ごとに表示 * Fix lint * アカウントの一括取得数を制限 * ストリーミング対応 * Fix * Fix * Fix * Fix some problems * Fix
This commit is contained in:
parent
5dec110dec
commit
f14c2d3ada
10 changed files with 258 additions and 7 deletions
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
55
spec/models/notification_group_spec.rb
Normal file
55
spec/models/notification_group_spec.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue