Merge remote-tracking branch 'parent/main' into upstream-20240822

This commit is contained in:
KMY 2024-08-22 09:18:29 +09:00
commit 55f11765ea
34 changed files with 401 additions and 601 deletions

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module KmyblueCapabilitiesHelper module KmyblueCapabilitiesHelper
KMYBLUE_API_VERSION = 1
def fedibird_capabilities def fedibird_capabilities
capabilities = %i( capabilities = %i(
enable_wide_emoji enable_wide_emoji

View file

@ -746,12 +746,12 @@ export function expandMentionedUsersFail(id, error) {
}; };
} }
function toggleReblogWithoutConfirmation(status, privacy) { function toggleReblogWithoutConfirmation(status, visibility) {
return (dispatch) => { return (dispatch) => {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog({ statusId: status.get('id') })); dispatch(unreblog({ statusId: status.get('id') }));
} else { } else {
dispatch(reblog({ statusId: status.get('id'), privacy })); dispatch(reblog({ statusId: status.get('id'), visibility }));
} }
}; };
} }

View file

@ -11,6 +11,7 @@ import type {
} from 'mastodon/api_types/notifications'; } from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications'; import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import { usePendingItems } from 'mastodon/initial_state';
import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import { import {
selectSettingsNotificationsExcludedTypes, 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( export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew', 'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch, getState }) => { (notification: ApiNotificationJSON, { dispatch, getState }) => {

View file

@ -10,7 +10,7 @@ import {
deleteAnnouncement, deleteAnnouncement,
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; 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 { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
@ -38,7 +38,7 @@ const randomUpTo = max =>
* @param {string} channelName * @param {string} channelName
* @param {Object.<string, string>} params * @param {Object.<string, string>} params
* @param {Object} options * @param {Object} options
* @param {function(Function): Promise<void>} [options.fallback] * @param {function(Function, Function): Promise<void>} [options.fallback]
* @param {function(): void} [options.fillGaps] * @param {function(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept] * @param {function(object): boolean} [options.accept]
* @returns {function(): void} * @returns {function(): void}
@ -53,11 +53,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
let pollingId; let pollingId;
/** /**
* @param {function(Function): Promise<void>} fallback * @param {function(Function, Function): Promise<void>} fallback
*/ */
const useFallback = async 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 // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
}; };
@ -144,10 +144,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/** /**
* @param {Function} dispatch * @param {Function} dispatch
* @param {Function} getState
*/ */
async function refreshHomeTimelineAndNotification(dispatch) { async function refreshHomeTimelineAndNotification(dispatch, getState) {
await dispatch(expandHomeTimeline({ maxId: undefined })); 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()); await dispatch(fetchAnnouncements());
} }

View file

@ -19,6 +19,7 @@ const exceptInvalidNotifications = (
export const apiFetchNotifications = async (params?: { export const apiFetchNotifications = async (params?: {
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string;
}) => { }) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({ const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET', method: 'GET',

View file

@ -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 (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
};

View file

@ -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 = (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
if (total === 1) {
return displayedName;
}
if (seeMoreHref)
return (
<FormattedMessage
id='name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a>'
values={{
name: displayedName,
count: total - 1,
a: (chunks) => <Link to={seeMoreHref}>{chunks}</Link>,
}}
/>
);
return (
<FormattedMessage
id='name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}}'
values={{ name: displayedName, count: total - 1 }}
/>
);
};

View file

@ -6,13 +6,27 @@ import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total) => {
<FormattedMessage if (total === 1)
id='notification.admin.sign_up' return (
defaultMessage='{name} signed up' <FormattedMessage
values={values} id='notification.admin.sign_up'
/> defaultMessage='{name} signed up'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.admin.sign_up.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} signed up'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationAdminSignUp: React.FC<{ export const NotificationAdminSignUp: React.FC<{
notification: NotificationGroupAdminSignUp; notification: NotificationGroupAdminSignUp;

View file

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react'; import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import type { NotificationGroupEmojiReaction } from 'mastodon/models/notification_group'; import type { NotificationGroupEmojiReaction } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
<FormattedMessage if (total === 1)
id='notification.emoji_reaction' return (
defaultMessage='{name} reacted your status with emoji' <FormattedMessage
values={values} id='notification.emoji_reaction'
/> defaultMessage='{name} reacted your status with emoji'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.emoji_reaction.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> reacted your post with emoji'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationEmojiReaction: React.FC<{ export const NotificationEmojiReaction: React.FC<{
notification: NotificationGroupEmojiReaction; notification: NotificationGroupEmojiReaction;

View file

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import type { NotificationGroupFavourite } from 'mastodon/models/notification_group'; import type { NotificationGroupFavourite } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
<FormattedMessage if (total === 1)
id='notification.favourite' return (
defaultMessage='{name} favorited your status' <FormattedMessage
values={values} id='notification.favourite'
/> defaultMessage='{name} favorited your status'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.favourite.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationFavourite: React.FC<{ export const NotificationFavourite: React.FC<{
notification: NotificationGroupFavourite; notification: NotificationGroupFavourite;

View file

@ -10,13 +10,27 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total) => {
<FormattedMessage if (total === 1)
id='notification.follow' return (
defaultMessage='{name} followed you' <FormattedMessage
values={values} id='notification.follow'
/> defaultMessage='{name} followed you'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} followed you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
const FollowerCount: React.FC<{ accountId: string }> = ({ accountId }) => { const FollowerCount: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((s) => s.accounts.get(accountId)); const account = useAppSelector((s) => s.accounts.get(accountId));

View file

@ -21,13 +21,27 @@ const messages = defineMessages({
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
}); });
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total) => {
<FormattedMessage if (total === 1)
id='notification.follow_request' return (
defaultMessage='{name} has requested to follow you' <FormattedMessage
values={values} id='notification.follow_request'
/> defaultMessage='{name} has requested to follow you'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow_request.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} has requested to follow you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationFollowRequest: React.FC<{ export const NotificationFollowRequest: React.FC<{
notification: NotificationGroupFollowRequest; notification: NotificationGroupFollowRequest;

View file

@ -14,11 +14,13 @@ import type { EmojiReactionGroup } from 'mastodon/models/notification_group';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { AvatarGroup } from './avatar_group'; import { AvatarGroup } from './avatar_group';
import { DisplayedName } from './displayed_name';
import { EmbeddedStatus } from './embedded_status'; import { EmbeddedStatus } from './embedded_status';
import { NamesList } from './names_list';
export type LabelRenderer = ( export type LabelRenderer = (
values: Record<string, React.ReactNode>, displayedName: JSX.Element,
total: number,
seeMoreHref?: string,
) => JSX.Element; ) => JSX.Element;
export const NotificationGroupWithStatus: React.FC<{ export const NotificationGroupWithStatus: React.FC<{
@ -54,15 +56,11 @@ export const NotificationGroupWithStatus: React.FC<{
const label = useMemo( const label = useMemo(
() => () =>
labelRenderer({ labelRenderer(
name: ( <DisplayedName accountIds={accountIds} />,
<NamesList count,
accountIds={accountIds} labelSeeMoreHref,
total={count} ),
seeMoreHref={labelSeeMoreHref}
/>
),
}),
[labelRenderer, accountIds, count, labelSeeMoreHref], [labelRenderer, accountIds, count, labelSeeMoreHref],
); );

View file

@ -11,10 +11,12 @@ import { NotificationWithStatus } from './notification_with_status';
const createLabelRenderer = ( const createLabelRenderer = (
notification: NotificationGroupListStatus, notification: NotificationGroupListStatus,
): LabelRenderer => { ): LabelRenderer => {
const renderer: LabelRenderer = (values) => { const renderer: LabelRenderer = (displayedName) => {
const list = notification.list; const list = notification.list;
let listHref: JSX.Element | undefined;
if (list) { if (list) {
const listLink = ( listHref = (
<bdi> <bdi>
<Link <Link
className='notification__display-name' className='notification__display-name'
@ -26,14 +28,13 @@ const createLabelRenderer = (
</Link> </Link>
</bdi> </bdi>
); );
values.listName = listLink;
} }
return ( return (
<FormattedMessage <FormattedMessage
id='notification.list_status' id='notification.list_status'
defaultMessage='{name} post is added to {listName}' defaultMessage='{name} post is added to {listName}'
values={values} values={{ name: displayedName, listName: listHref }}
/> />
); );
}; };

View file

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import type { NotificationGroupReblog } from 'mastodon/models/notification_group'; import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
<FormattedMessage if (total === 1)
id='notification.reblog' return (
defaultMessage='{name} boosted your status' <FormattedMessage
values={values} id='notification.reblog'
/> defaultMessage='{name} boosted your status'
); values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.reblog.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> boosted your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationReblog: React.FC<{ export const NotificationReblog: React.FC<{
notification: NotificationGroupReblog; notification: NotificationGroupReblog;

View file

@ -6,11 +6,11 @@ import type { NotificationGroupStatus } from 'mastodon/models/notification_group
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status'; import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage <FormattedMessage
id='notification.status' id='notification.status'
defaultMessage='{name} just posted' defaultMessage='{name} just posted'
values={values} values={{ name: displayedName }}
/> />
); );

View file

@ -6,11 +6,11 @@ import type { NotificationGroupStatusReference } from 'mastodon/models/notificat
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status'; import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage <FormattedMessage
id='notification.status_reference' id='notification.status_reference'
defaultMessage='{name} quoted your post' defaultMessage='{name} quoted your post'
values={values} values={{ name: displayedName }}
/> />
); );

View file

@ -6,11 +6,11 @@ import type { NotificationGroupUpdate } from 'mastodon/models/notification_group
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status'; import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => ( const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage <FormattedMessage
id='notification.update' id='notification.update'
defaultMessage='{name} edited a post' defaultMessage='{name} edited a post'
values={values} values={{ name: displayedName }}
/> />
); );

View file

@ -15,7 +15,7 @@ import { Icon } from 'mastodon/components/icon';
import Status from 'mastodon/containers/status_container'; import Status from 'mastodon/containers/status_container';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { NamesList } from './names_list'; import { DisplayedName } from './displayed_name';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
export const NotificationWithStatus: React.FC<{ export const NotificationWithStatus: React.FC<{
@ -42,10 +42,7 @@ export const NotificationWithStatus: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const label = useMemo( const label = useMemo(
() => () => labelRenderer(<DisplayedName accountIds={accountIds} />, count),
labelRenderer({
name: <NamesList accountIds={accountIds} total={count} />,
}),
[labelRenderer, accountIds, count], [labelRenderer, accountIds, count],
); );

View file

@ -572,8 +572,6 @@
"mute_modal.title": "Mute user?", "mute_modal.title": "Mute user?",
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.", "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.", "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 <a>{count, plural, one {# other} other {# others}}</a>",
"navigation_bar.about": "About", "navigation_bar.about": "About",
"navigation_bar.advanced_interface": "Open in advanced web interface", "navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.antennas": "Antenna", "navigation_bar.antennas": "Antenna",
@ -612,10 +610,15 @@
"notification.admin.report_statuses": "{name} reported {target} for {category}", "notification.admin.report_statuses": "{name} reported {target} for {category}",
"notification.admin.report_statuses_other": "{name} reported {target}", "notification.admin.report_statuses_other": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up", "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 <a>{count, plural, one {# other} other {# others}}</a> reacted your post with emoji",
"notification.favourite": "{name} favorited your post", "notification.favourite": "{name} favorited your post",
"notification.favourite.name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your post",
"notification.follow": "{name} followed you", "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} 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.mention": "Mention",
"notification.label.private_mention": "Private mention", "notification.label.private_mention": "Private mention",
"notification.label.private_reply": "Private reply", "notification.label.private_reply": "Private reply",
@ -635,6 +638,7 @@
"notification.own_poll": "Your poll has ended", "notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you voted in has ended", "notification.poll": "A poll you voted in has ended",
"notification.reblog": "{name} boosted your post", "notification.reblog": "{name} boosted your post",
"notification.reblog.name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a> boosted your post",
"notification.relationships_severance_event": "Lost connections with {name}", "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.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.", "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.",

View file

@ -577,6 +577,7 @@
"notification.admin.report_statuses_other": "{name}さんが{target}さんを通報しました", "notification.admin.report_statuses_other": "{name}さんが{target}さんを通報しました",
"notification.admin.sign_up": "{name}さんがサインアップしました", "notification.admin.sign_up": "{name}さんがサインアップしました",
"notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました", "notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました",
"notification.emoji_reaction.name_and_others_with_link": "{name}さんと<a>{count, plural, other {他#名}}</a>があなたの投稿に絵文字をつけました",
"notification.favourite": "{name}さんがお気に入りしました", "notification.favourite": "{name}さんがお気に入りしました",
"notification.follow": "{name}さんにフォローされました", "notification.follow": "{name}さんにフォローされました",
"notification.follow_request": "{name}さんがあなたにフォローリクエストしました", "notification.follow_request": "{name}さんがあなたにフォローリクエストしました",

View file

@ -20,12 +20,16 @@ import {
mountNotifications, mountNotifications,
unmountNotifications, unmountNotifications,
refreshStaleNotificationGroups, refreshStaleNotificationGroups,
pollRecentNotifications,
} from 'mastodon/actions/notification_groups'; } from 'mastodon/actions/notification_groups';
import { import {
disconnectTimeline, disconnectTimeline,
timelineDelete, timelineDelete,
} from 'mastodon/actions/timelines_typed'; } 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 { compareId } from 'mastodon/compare_id';
import { usePendingItems } from 'mastodon/initial_state'; import { usePendingItems } from 'mastodon/initial_state';
import { 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<NotificationGroupsState>( export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
initialState, initialState,
(builder) => { (builder) => {
@ -344,86 +448,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
updateLastReadId(state); updateLastReadId(state);
}) })
.addCase(fetchNotificationsGap.fulfilled, (state, action) => { .addCase(fetchNotificationsGap.fulfilled, (state, action) => {
const { notifications } = action.payload; state.groups = fillNotificationsGap(
state.groups,
// find the gap in the existing notifications action.meta.arg.gap,
const gapIndex = state.groups.findIndex( action.payload.notifications,
(groupOrGap) =>
groupOrGap.type === 'gap' &&
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
groupOrGap.maxId === action.meta.arg.gap.maxId,
); );
state.isLoading = false;
if (gapIndex < 0) updateLastReadId(state);
// We do not know where to insert, let's return })
return; .addCase(pollRecentNotifications.fulfilled, (state, action) => {
if (usePendingItems) {
// Filling a disconnection gap means we're getting historical data const gap = ensureLeadingGap(state.pendingGroups);
// about groups we may know or may not know about. state.pendingGroups = fillNotificationsGap(
state.pendingGroups,
// The notifications timeline is split in two by the gap, with gap,
// group information newer than the gap, and group information older action.payload.notifications,
// than the gap. );
} else {
// Filling a gap should not touch anything before the gap, so any const gap = ensureLeadingGap(state.groups);
// information on groups already appearing before the gap should be state.groups = fillNotificationsGap(
// discarded, while any information on groups appearing after the gap state.groups,
// can be updated and re-ordered. gap,
action.payload.notifications,
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),
); );
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; state.isLoading = false;
updateLastReadId(state); updateLastReadId(state);
trimNotifications(state);
}) })
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => { .addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload; const notification = action.payload;
@ -438,10 +492,11 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}) })
.addCase(disconnectTimeline, (state, action) => { .addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') { if (action.payload.timeline === 'home') {
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') { const groups = usePendingItems ? state.pendingGroups : state.groups;
state.groups.unshift({ if (groups.length > 0 && groups[0]?.type !== 'gap') {
groups.unshift({
type: 'gap', type: 'gap',
sinceId: state.groups[0]?.page_min_id, sinceId: groups[0]?.page_min_id,
}); });
} }
} }
@ -488,12 +543,13 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
} }
} }
} }
trimNotifications(state);
}); });
// Then build the consolidated list and clear pending groups // Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups); state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = []; state.pendingGroups = [];
mergeGaps(state.groups);
trimNotifications(state);
}) })
.addCase(updateScrollPosition.fulfilled, (state, action) => { .addCase(updateScrollPosition.fulfilled, (state, action) => {
state.scrolledToTop = action.payload.top; state.scrolledToTop = action.payload.top;
@ -553,13 +609,21 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}, },
) )
.addMatcher( .addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), isAnyOf(
fetchNotifications.pending,
fetchNotificationsGap.pending,
pollRecentNotifications.pending,
),
(state) => { (state) => {
state.isLoading = true; state.isLoading = true;
}, },
) )
.addMatcher( .addMatcher(
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected), isAnyOf(
fetchNotifications.rejected,
fetchNotificationsGap.rejected,
pollRecentNotifications.rejected,
),
(state) => { (state) => {
state.isLoading = false; state.isLoading = false;
}, },

View file

@ -414,9 +414,6 @@
border-color: transparent transparent $white; border-color: transparent transparent $white;
} }
.hero-widget,
.moved-account-widget,
.memoriam-widget,
.activity-stream, .activity-stream,
.nothing-here, .nothing-here,
.directory__tag > a, .directory__tag > a,

View file

@ -130,21 +130,11 @@
.older { .older {
float: left; float: left;
padding-inline-start: 0; padding-inline-start: 0;
.fa {
display: inline-block;
margin-inline-end: 5px;
}
} }
.newer { .newer {
float: right; float: right;
padding-inline-end: 0; padding-inline-end: 0;
.fa {
display: inline-block;
margin-inline-start: 5px;
}
} }
.disabled { .disabled {

View file

@ -122,10 +122,6 @@ $content-width: 840px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
i.fa {
margin-inline-end: 5px;
}
&:hover { &:hover {
color: $primary-text-color; color: $primary-text-color;
transition: all 100ms linear; transition: all 100ms linear;
@ -306,10 +302,6 @@ $content-width: 840px;
box-shadow: none; box-shadow: none;
} }
.directory__tag .table-action-link .fa {
color: inherit;
}
.directory__tag h4 { .directory__tag h4 {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;

View file

@ -2981,10 +2981,6 @@ $ui-header-logo-wordmark-width: 99px;
padding-inline-end: 30px; padding-inline-end: 30px;
} }
.search__icon .fa {
top: 15px;
}
.scrollable { .scrollable {
overflow: visible; overflow: visible;
@ -3520,26 +3516,6 @@ $ui-header-logo-wordmark-width: 99px;
height: calc(100% - 10px); height: calc(100% - 10px);
overflow-y: hidden; 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 { .compose-form {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@ -6529,7 +6505,7 @@ a.status-card {
} }
.boost-modal__container { .boost-modal__container {
overflow-x: scroll; overflow-y: auto;
padding: 10px; padding: 10px;
.status { .status {

View file

@ -113,10 +113,6 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.fa {
flex: 0 0 auto;
}
strong { strong {
font-weight: 700; font-weight: 700;
} }

View file

@ -942,10 +942,6 @@ code {
font-weight: 700; font-weight: 700;
} }
} }
.fa {
font-weight: 400;
}
} }
} }
} }

View file

@ -41,14 +41,6 @@ body.rtl {
no-repeat left 8px center / auto 16px; no-repeat left 8px center / auto 16px;
} }
.fa-chevron-left::before {
content: '\F054';
}
.fa-chevron-right::before {
content: '\F053';
}
.dismissable-banner, .dismissable-banner,
.warning-banner { .warning-banner {
&__action { &__action {

View file

@ -142,11 +142,6 @@ a.table-action-link {
color: $highlight-text-color; color: $highlight-text-color;
} }
i.fa {
font-weight: 400;
margin-inline-end: 5px;
}
&:first-child { &:first-child {
padding-inline-start: 0; padding-inline-start: 0;
} }

View file

@ -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 { .accounts-table {
width: 100%; width: 100%;
@ -367,9 +75,7 @@
padding-inline-end: 16px; padding-inline-end: 16px;
} }
.fa { .icon {
font-size: 16px;
&.active { &.active {
color: $highlight-text-color; 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;
}
}
}

View file

@ -13,7 +13,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description, attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration, :usage, :thumbnail, :languages, :configuration,
:registrations, :fedibird_capabilities :registrations, :fedibird_capabilities, :api_versions
has_one :contact, serializer: ContactSerializer has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer has_many :rules, serializer: REST::RuleSerializer
@ -119,6 +119,13 @@ class REST::InstanceSerializer < ActiveModel::Serializer
} }
end end
def api_versions
{
mastodon: 1,
kmyblue: KMYBLUE_API_VERSION,
}
end
private private
def registrations_enabled? def registrations_enabled?

View file

@ -1,11 +1,10 @@
:ruby :ruby
show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired? 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 total_votes_count = poll.voters_count || poll.votes_count
.poll .poll
%ul %ul
- poll.loaded_options.each_with_index do |option, index| - poll.loaded_options.each do |option|
%li %li
- if show_results - if show_results
- percent = total_votes_count.positive? ? 100 * option.votes_count / total_votes_count : 0 - percent = total_votes_count.positive? ? 100 * option.votes_count / total_votes_count : 0
@ -14,9 +13,6 @@
#{percent.round}% #{percent.round}%
%span.poll__option__text %span.poll__option__text
= prerender_custom_emojis(h(option.title), status.emojis) = 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' } %progress{ max: 100, value: [percent, 1].max, 'aria-hidden': 'true' }
%span.poll__chart %span.poll__chart

View file

@ -18,6 +18,7 @@ describe 'Instances' do
expect(body_as_json) expect(body_as_json)
.to be_present .to be_present
.and include(title: 'Mastodon') .and include(title: 'Mastodon')
.and include_api_versions
.and include_configuration_limits .and include_configuration_limits
end end
end end
@ -32,6 +33,7 @@ describe 'Instances' do
expect(body_as_json) expect(body_as_json)
.to be_present .to be_present
.and include(title: 'Mastodon') .and include(title: 'Mastodon')
.and include_api_versions
.and include_configuration_limits .and include_configuration_limits
end end
end end
@ -53,5 +55,13 @@ describe 'Instances' do
) )
) )
end end
def include_api_versions
include(
api_versions: include(
mastodon: anything
)
)
end
end end
end end