mirror of
https://github.com/funamitech/mastodon
synced 2024-11-27 06:18:53 +09:00
Merge commit '82344342c1c5adb3f6a4b376559db737a9e982b7' into glitch-soc/merge-upstream
This commit is contained in:
commit
c75fe09e2b
@ -50,6 +50,11 @@ You can contribute in the following ways:
|
||||
|
||||
If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
|
||||
|
||||
Please review the org-level [contribution guidelines] for high-level acceptance
|
||||
criteria guidance.
|
||||
|
||||
[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md
|
||||
|
||||
## API Changes and Additions
|
||||
|
||||
Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation).
|
||||
|
@ -766,8 +766,9 @@ GEM
|
||||
ruby-saml (1.16.0)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.2.1)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
rufus-scheduler (3.9.1)
|
||||
|
@ -12,10 +12,27 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||
with_read_replica do
|
||||
@notifications = load_notifications
|
||||
@group_metadata = load_group_metadata
|
||||
@grouped_notifications = load_grouped_notifications
|
||||
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
||||
@sample_accounts = @grouped_notifications.flat_map(&:sample_accounts)
|
||||
|
||||
# Preload associations to avoid N+1s
|
||||
ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
|
||||
end
|
||||
|
||||
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
|
||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
|
||||
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
|
||||
|
||||
span.add_attributes(
|
||||
'app.notification_grouping.count' => @grouped_notifications.size,
|
||||
'app.notification_grouping.sample_account.count' => @sample_accounts.size,
|
||||
'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size,
|
||||
'app.notification_grouping.status.count' => statuses.size,
|
||||
'app.notification_grouping.status.unique_count' => statuses.uniq.size
|
||||
)
|
||||
|
||||
render json: @grouped_notifications, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@ -36,25 +53,35 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||
private
|
||||
|
||||
def load_notifications
|
||||
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
|
||||
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
|
||||
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
|
||||
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
|
||||
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
|
||||
preload_collection(target_statuses, Status)
|
||||
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
|
||||
preload_collection(target_statuses, Status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def load_group_metadata
|
||||
return {} if @notifications.empty?
|
||||
|
||||
browserable_account_notifications
|
||||
.where(group_key: @notifications.filter_map(&:group_key))
|
||||
.where(id: (@notifications.last.id)..(@notifications.first.id))
|
||||
.group(:group_key)
|
||||
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
|
||||
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
|
||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
|
||||
browserable_account_notifications
|
||||
.where(group_key: @notifications.filter_map(&:group_key))
|
||||
.where(id: (@notifications.last.id)..(@notifications.first.id))
|
||||
.group(:group_key)
|
||||
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
|
||||
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
|
||||
end
|
||||
end
|
||||
|
||||
def load_grouped_notifications
|
||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
|
||||
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
|
||||
end
|
||||
end
|
||||
|
||||
def browserable_account_notifications
|
||||
|
@ -75,9 +75,17 @@ interface MarkerParam {
|
||||
}
|
||||
|
||||
function getLastNotificationId(state: RootState): string | undefined {
|
||||
// @ts-expect-error state.notifications is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
||||
return state.getIn(['notifications', 'lastReadId']);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const enableBeta = state.settings.getIn(
|
||||
['notifications', 'groupingBeta'],
|
||||
false,
|
||||
) as boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return enableBeta
|
||||
? state.notificationGroups.lastReadId
|
||||
: // @ts-expect-error state.notifications is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
state.getIn(['notifications', 'lastReadId']);
|
||||
}
|
||||
|
||||
const buildPostMarkersParams = (state: RootState) => {
|
||||
|
144
app/javascript/mastodon/actions/notification_groups.ts
Normal file
144
app/javascript/mastodon/actions/notification_groups.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
apiClearNotifications,
|
||||
apiFetchNotifications,
|
||||
} from 'mastodon/api/notifications';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type {
|
||||
ApiNotificationGroupJSON,
|
||||
ApiNotificationJSON,
|
||||
} from 'mastodon/api_types/notifications';
|
||||
import { allNotificationTypes } from 'mastodon/api_types/notifications';
|
||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
|
||||
import {
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
} from 'mastodon/selectors/settings';
|
||||
import type { AppDispatch } from 'mastodon/store';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
createDataLoadingThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||
import { saveSettings } from './settings';
|
||||
|
||||
function excludeAllTypesExcept(filter: string) {
|
||||
return allNotificationTypes.filter((item) => item !== filter);
|
||||
}
|
||||
|
||||
function dispatchAssociatedRecords(
|
||||
dispatch: AppDispatch,
|
||||
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
|
||||
) {
|
||||
const fetchedAccounts: ApiAccountJSON[] = [];
|
||||
const fetchedStatuses: ApiStatusJSON[] = [];
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
if ('sample_accounts' in notification) {
|
||||
fetchedAccounts.push(...notification.sample_accounts);
|
||||
}
|
||||
|
||||
if (notification.type === 'admin.report') {
|
||||
fetchedAccounts.push(notification.report.target_account);
|
||||
}
|
||||
|
||||
if (notification.type === 'moderation_warning') {
|
||||
fetchedAccounts.push(notification.moderation_warning.target_account);
|
||||
}
|
||||
|
||||
if ('status' in notification) {
|
||||
fetchedStatuses.push(notification.status);
|
||||
}
|
||||
});
|
||||
|
||||
if (fetchedAccounts.length > 0)
|
||||
dispatch(importFetchedAccounts(fetchedAccounts));
|
||||
|
||||
if (fetchedStatuses.length > 0)
|
||||
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||
}
|
||||
|
||||
export const fetchNotifications = createDataLoadingThunk(
|
||||
'notificationGroups/fetch',
|
||||
async (_params, { getState }) => {
|
||||
const activeFilter =
|
||||
selectSettingsNotificationsQuickFilterActive(getState());
|
||||
|
||||
return apiFetchNotifications({
|
||||
exclude_types:
|
||||
activeFilter === 'all'
|
||||
? selectSettingsNotificationsExcludedTypes(getState())
|
||||
: excludeAllTypesExcept(activeFilter),
|
||||
});
|
||||
},
|
||||
({ notifications }, { dispatch }) => {
|
||||
dispatchAssociatedRecords(dispatch, notifications);
|
||||
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
|
||||
notifications;
|
||||
|
||||
// TODO: might be worth not using gaps for that…
|
||||
// if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
|
||||
if (notifications.length > 1)
|
||||
payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });
|
||||
|
||||
return payload;
|
||||
// dispatch(submitMarkers());
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchNotificationsGap = createDataLoadingThunk(
|
||||
'notificationGroups/fetchGap',
|
||||
async (params: { gap: NotificationGap }) =>
|
||||
apiFetchNotifications({ max_id: params.gap.maxId }),
|
||||
|
||||
({ notifications }, { dispatch }) => {
|
||||
dispatchAssociatedRecords(dispatch, notifications);
|
||||
|
||||
return { notifications };
|
||||
},
|
||||
);
|
||||
|
||||
export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||
'notificationGroups/processNew',
|
||||
(notification: ApiNotificationJSON, { dispatch }) => {
|
||||
dispatchAssociatedRecords(dispatch, [notification]);
|
||||
|
||||
return notification;
|
||||
},
|
||||
);
|
||||
|
||||
export const loadPending = createAction('notificationGroups/loadPending');
|
||||
|
||||
export const updateScrollPosition = createAction<{ top: boolean }>(
|
||||
'notificationGroups/updateScrollPosition',
|
||||
);
|
||||
|
||||
export const setNotificationsFilter = createAppAsyncThunk(
|
||||
'notifications/filter/set',
|
||||
({ filterType }: { filterType: string }, { dispatch }) => {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_FILTER_SET,
|
||||
path: ['notifications', 'quickFilter', 'active'],
|
||||
value: filterType,
|
||||
});
|
||||
// dispatch(expandNotifications({ forceLoad: true }));
|
||||
void dispatch(fetchNotifications());
|
||||
dispatch(saveSettings());
|
||||
},
|
||||
);
|
||||
|
||||
export const clearNotifications = createDataLoadingThunk(
|
||||
'notifications/clear',
|
||||
() => apiClearNotifications(),
|
||||
);
|
||||
|
||||
export const markNotificationsAsRead = createAction(
|
||||
'notificationGroups/markAsRead',
|
||||
);
|
||||
|
||||
export const mountNotifications = createAction('notificationGroups/mount');
|
||||
export const unmountNotifications = createAction('notificationGroups/unmount');
|
@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
|
||||
|
||||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
|
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
||||
|
||||
@ -174,7 +173,7 @@ const noOp = () => {};
|
||||
|
||||
let expandNotificationsController = new AbortController();
|
||||
|
||||
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
|
||||
export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
|
||||
return (dispatch, getState) => {
|
||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||
const notifications = getState().get('notifications');
|
||||
@ -257,16 +256,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
|
||||
};
|
||||
}
|
||||
|
||||
export function clearNotifications() {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_CLEAR,
|
||||
});
|
||||
|
||||
api().post('/api/v1/notifications/clear');
|
||||
};
|
||||
}
|
||||
|
||||
export function scrollTopNotifications(top) {
|
||||
return {
|
||||
type: NOTIFICATIONS_SCROLL_TOP,
|
||||
|
18
app/javascript/mastodon/actions/notifications_migration.tsx
Normal file
18
app/javascript/mastodon/actions/notifications_migration.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { createAppAsyncThunk } from 'mastodon/store';
|
||||
|
||||
import { fetchNotifications } from './notification_groups';
|
||||
import { expandNotifications } from './notifications';
|
||||
|
||||
export const initializeNotifications = createAppAsyncThunk(
|
||||
'notifications/initialize',
|
||||
(_, { dispatch, getState }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const enableBeta = getState().settings.getIn(
|
||||
['notifications', 'groupingBeta'],
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
if (enableBeta) void dispatch(fetchNotifications());
|
||||
else dispatch(expandNotifications());
|
||||
},
|
||||
);
|
@ -1,11 +1,6 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiAccountJSON } from '../api_types/accounts';
|
||||
// To be replaced once ApiNotificationJSON type exists
|
||||
interface FakeApiNotificationJSON {
|
||||
type: string;
|
||||
account: ApiAccountJSON;
|
||||
}
|
||||
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
|
||||
|
||||
export const notificationsUpdate = createAction(
|
||||
'notifications/update',
|
||||
@ -13,7 +8,7 @@ export const notificationsUpdate = createAction(
|
||||
playSound,
|
||||
...args
|
||||
}: {
|
||||
notification: FakeApiNotificationJSON;
|
||||
notification: ApiNotificationJSON;
|
||||
usePendingItems: boolean;
|
||||
playSound: boolean;
|
||||
}) => ({
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { updateConversations } from './conversations';
|
||||
import { processNewNotificationForGroups } from './notification_groups';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateStatus } from './statuses';
|
||||
import {
|
||||
@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
break;
|
||||
case 'notification':
|
||||
case 'notification': {
|
||||
// @ts-expect-error
|
||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||
const notificationJSON = JSON.parse(data.payload);
|
||||
dispatch(updateNotifications(notificationJSON, messages, locale));
|
||||
// TODO: remove this once the groups feature replaces the previous one
|
||||
if(getState().notificationGroups.groups.length > 0) {
|
||||
dispatch(processNewNotificationForGroups(notificationJSON));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'conversation':
|
||||
// @ts-expect-error
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
|
18
app/javascript/mastodon/api/notifications.ts
Normal file
18
app/javascript/mastodon/api/notifications.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import api, { apiRequest, getLinks } from 'mastodon/api';
|
||||
import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';
|
||||
|
||||
export const apiFetchNotifications = async (params?: {
|
||||
exclude_types?: string[];
|
||||
max_id?: string;
|
||||
}) => {
|
||||
const response = await api().request<ApiNotificationGroupJSON[]>({
|
||||
method: 'GET',
|
||||
url: '/api/v2_alpha/notifications',
|
||||
params,
|
||||
});
|
||||
|
||||
return { notifications: response.data, links: getLinks(response) };
|
||||
};
|
||||
|
||||
export const apiClearNotifications = () =>
|
||||
apiRequest<undefined>('POST', 'v1/notifications/clear');
|
145
app/javascript/mastodon/api_types/notifications.ts
Normal file
145
app/javascript/mastodon/api_types/notifications.ts
Normal file
@ -0,0 +1,145 @@
|
||||
// See app/serializers/rest/notification_group_serializer.rb
|
||||
|
||||
import type { AccountWarningAction } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiReportJSON } from './reports';
|
||||
import type { ApiStatusJSON } from './statuses';
|
||||
|
||||
// See app/model/notification.rb
|
||||
export const allNotificationTypes = [
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reblog',
|
||||
'mention',
|
||||
'poll',
|
||||
'status',
|
||||
'update',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
'moderation_warning',
|
||||
'severed_relationships',
|
||||
];
|
||||
|
||||
export type NotificationWithStatusType =
|
||||
| 'favourite'
|
||||
| 'reblog'
|
||||
| 'status'
|
||||
| 'mention'
|
||||
| 'poll'
|
||||
| 'update';
|
||||
|
||||
export type NotificationType =
|
||||
| NotificationWithStatusType
|
||||
| 'follow'
|
||||
| 'follow_request'
|
||||
| 'moderation_warning'
|
||||
| 'severed_relationships'
|
||||
| 'admin.sign_up'
|
||||
| 'admin.report';
|
||||
|
||||
export interface BaseNotificationJSON {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
created_at: string;
|
||||
group_key: string;
|
||||
account: ApiAccountJSON;
|
||||
}
|
||||
|
||||
export interface BaseNotificationGroupJSON {
|
||||
group_key: string;
|
||||
notifications_count: number;
|
||||
type: NotificationType;
|
||||
sample_accounts: ApiAccountJSON[];
|
||||
latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly
|
||||
most_recent_notification_id: string;
|
||||
page_min_id?: string;
|
||||
page_max_id?: string;
|
||||
}
|
||||
|
||||
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
|
||||
type: NotificationWithStatusType;
|
||||
status: ApiStatusJSON;
|
||||
}
|
||||
|
||||
interface NotificationWithStatusJSON extends BaseNotificationJSON {
|
||||
type: NotificationWithStatusType;
|
||||
status: ApiStatusJSON;
|
||||
}
|
||||
|
||||
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||
type: 'admin.report';
|
||||
report: ApiReportJSON;
|
||||
}
|
||||
|
||||
interface ReportNotificationJSON extends BaseNotificationJSON {
|
||||
type: 'admin.report';
|
||||
report: ApiReportJSON;
|
||||
}
|
||||
|
||||
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
|
||||
interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||
type: SimpleNotificationTypes;
|
||||
}
|
||||
|
||||
interface SimpleNotificationJSON extends BaseNotificationJSON {
|
||||
type: SimpleNotificationTypes;
|
||||
}
|
||||
|
||||
export interface ApiAccountWarningJSON {
|
||||
id: string;
|
||||
action: AccountWarningAction;
|
||||
text: string;
|
||||
status_ids: string[];
|
||||
created_at: string;
|
||||
target_account: ApiAccountJSON;
|
||||
appeal: unknown;
|
||||
}
|
||||
|
||||
interface ModerationWarningNotificationGroupJSON
|
||||
extends BaseNotificationGroupJSON {
|
||||
type: 'moderation_warning';
|
||||
moderation_warning: ApiAccountWarningJSON;
|
||||
}
|
||||
|
||||
interface ModerationWarningNotificationJSON extends BaseNotificationJSON {
|
||||
type: 'moderation_warning';
|
||||
moderation_warning: ApiAccountWarningJSON;
|
||||
}
|
||||
|
||||
export interface ApiAccountRelationshipSeveranceEventJSON {
|
||||
id: string;
|
||||
type: 'account_suspension' | 'domain_block' | 'user_domain_block';
|
||||
purged: boolean;
|
||||
target_name: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AccountRelationshipSeveranceNotificationGroupJSON
|
||||
extends BaseNotificationGroupJSON {
|
||||
type: 'severed_relationships';
|
||||
event: ApiAccountRelationshipSeveranceEventJSON;
|
||||
}
|
||||
|
||||
interface AccountRelationshipSeveranceNotificationJSON
|
||||
extends BaseNotificationJSON {
|
||||
type: 'severed_relationships';
|
||||
event: ApiAccountRelationshipSeveranceEventJSON;
|
||||
}
|
||||
|
||||
export type ApiNotificationJSON =
|
||||
| SimpleNotificationJSON
|
||||
| ReportNotificationJSON
|
||||
| AccountRelationshipSeveranceNotificationJSON
|
||||
| NotificationWithStatusJSON
|
||||
| ModerationWarningNotificationJSON;
|
||||
|
||||
export type ApiNotificationGroupJSON =
|
||||
| SimpleNotificationGroupJSON
|
||||
| ReportNotificationGroupJSON
|
||||
| AccountRelationshipSeveranceNotificationGroupJSON
|
||||
| NotificationGroupWithStatusJSON
|
||||
| ModerationWarningNotificationGroupJSON;
|
16
app/javascript/mastodon/api_types/reports.ts
Normal file
16
app/javascript/mastodon/api_types/reports.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
|
||||
export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation';
|
||||
|
||||
export interface ApiReportJSON {
|
||||
id: string;
|
||||
action_taken: unknown;
|
||||
action_taken_at: unknown;
|
||||
category: ReportCategory;
|
||||
comment: string;
|
||||
forwarded: boolean;
|
||||
created_at: string;
|
||||
status_ids: string[];
|
||||
rule_ids: string[];
|
||||
target_account: ApiAccountJSON;
|
||||
}
|
@ -9,18 +9,18 @@ const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
});
|
||||
|
||||
interface Props {
|
||||
interface Props<T> {
|
||||
disabled: boolean;
|
||||
maxId: string;
|
||||
onClick: (maxId: string) => void;
|
||||
param: T;
|
||||
onClick: (params: T) => void;
|
||||
}
|
||||
|
||||
export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => {
|
||||
export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(maxId);
|
||||
}, [maxId, onClick]);
|
||||
onClick(param);
|
||||
}, [param, onClick]);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
@ -116,6 +116,8 @@ class Status extends ImmutablePureComponent {
|
||||
cacheMediaWidth: PropTypes.func,
|
||||
cachedMediaWidth: PropTypes.number,
|
||||
scrollKey: PropTypes.string,
|
||||
skipPrepend: PropTypes.bool,
|
||||
avatarSize: PropTypes.number,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
@ -353,7 +355,7 @@ class Status extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
|
||||
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
@ -539,7 +541,7 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={46} />;
|
||||
statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
@ -550,7 +552,7 @@ class Status extends ImmutablePureComponent {
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{prepend}
|
||||
{!skipPrepend && prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
|
||||
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
||||
|
@ -107,7 +107,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
<LoadGap
|
||||
key={'gap:' + statusIds.get(index + 1)}
|
||||
disabled={isLoading}
|
||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||
param={index > 0 ? statusIds.get(index - 1) : null}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ import { cancelReplyCompose } from 'mastodon/actions/compose';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
@ -33,8 +34,6 @@ export const EditIndicator = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = { __html: status.get('contentHtml') };
|
||||
|
||||
return (
|
||||
<div className='edit-indicator'>
|
||||
<div className='edit-indicator__header'>
|
||||
@ -49,7 +48,12 @@ export const EditIndicator = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
|
||||
<EmbeddedStatusContent
|
||||
className='edit-indicator__content translate'
|
||||
content={status.get('contentHtml')}
|
||||
language={status.get('language')}
|
||||
mentions={status.get('mentions')}
|
||||
/>
|
||||
|
||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||
<div className='edit-indicator__attachments'>
|
||||
|
@ -9,6 +9,7 @@ import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
|
||||
|
||||
export const ReplyIndicator = () => {
|
||||
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
|
||||
@ -19,8 +20,6 @@ export const ReplyIndicator = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = { __html: status.get('contentHtml') };
|
||||
|
||||
return (
|
||||
<div className='reply-indicator'>
|
||||
<div className='reply-indicator__line' />
|
||||
@ -34,7 +33,12 @@ export const ReplyIndicator = () => {
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
|
||||
<EmbeddedStatusContent
|
||||
className='reply-indicator__content translate'
|
||||
content={status.get('contentHtml')}
|
||||
language={status.get('language')}
|
||||
mentions={status.get('mentions')}
|
||||
/>
|
||||
|
||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||
<div className='reply-indicator__attachments'>
|
||||
|
@ -53,6 +53,7 @@ class ColumnSettings extends PureComponent {
|
||||
|
||||
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
|
||||
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
|
||||
const groupingShowStr = <FormattedMessage id='notifications.column_settings.beta.grouping' defaultMessage='Group notifications' />;
|
||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
||||
@ -104,6 +105,16 @@ class ColumnSettings extends PureComponent {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-beta'>
|
||||
<h3 id='notifications-beta'>
|
||||
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-unread-markers'>
|
||||
<h3 id='notifications-unread-markers'>
|
||||
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
|
||||
|
@ -35,7 +35,9 @@ export const FilteredNotificationsBanner: React.FC = () => {
|
||||
className='filtered-notifications-banner'
|
||||
to='/notifications/requests'
|
||||
>
|
||||
<Icon icon={InventoryIcon} id='filtered-notifications' />
|
||||
<div className='notification-group__icon'>
|
||||
<Icon icon={InventoryIcon} id='filtered-notifications' />
|
||||
</div>
|
||||
|
||||
<div className='filtered-notifications-banner__text'>
|
||||
<strong>
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import GavelIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { AccountWarningAction } from 'mastodon/models/notification_group';
|
||||
|
||||
// This needs to be kept in sync with app/models/account_warning.rb
|
||||
const messages = defineMessages({
|
||||
@ -36,19 +39,18 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
interface Props {
|
||||
action:
|
||||
| 'none'
|
||||
| 'disable'
|
||||
| 'mark_statuses_as_sensitive'
|
||||
| 'delete_statuses'
|
||||
| 'sensitive'
|
||||
| 'silence'
|
||||
| 'suspend';
|
||||
action: AccountWarningAction;
|
||||
id: string;
|
||||
hidden: boolean;
|
||||
hidden?: boolean;
|
||||
unread?: boolean;
|
||||
}
|
||||
|
||||
export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
|
||||
export const ModerationWarning: React.FC<Props> = ({
|
||||
action,
|
||||
id,
|
||||
hidden,
|
||||
unread,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (hidden) {
|
||||
@ -56,23 +58,32 @@ export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/disputes/strikes/${id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='notification__moderation-warning'
|
||||
<div
|
||||
role='button'
|
||||
className={classNames(
|
||||
'notification-group notification-group--link notification-group--moderation-warning focusable',
|
||||
{ 'notification-group--unread': unread },
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon id='warning' icon={GavelIcon} />
|
||||
<div className='notification-group__icon'>
|
||||
<Icon id='warning' icon={GavelIcon} />
|
||||
</div>
|
||||
|
||||
<div className='notification__moderation-warning__content'>
|
||||
<div className='notification-group__main'>
|
||||
<p>{intl.formatMessage(messages[action])}</p>
|
||||
<span className='link-button'>
|
||||
<a
|
||||
href={`/disputes/strikes/${id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='link-button'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notification.moderation-warning.learn_more'
|
||||
defaultMessage='Learn more'
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -34,7 +34,7 @@ const messages = defineMessages({
|
||||
favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' },
|
||||
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
|
||||
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' },
|
||||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
|
||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||
@ -340,7 +340,7 @@ class Notification extends ImmutablePureComponent {
|
||||
{ownPoll ? (
|
||||
<FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
|
||||
) : (
|
||||
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
|
||||
<FormattedMessage id='notification.poll' defaultMessage='A poll you voted in has ended' />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
@ -13,7 +15,7 @@ const messages = defineMessages({
|
||||
user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' },
|
||||
});
|
||||
|
||||
export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden }) => {
|
||||
export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden, unread }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (hidden) {
|
||||
@ -21,14 +23,14 @@ export const RelationshipsSeveranceEvent = ({ type, target, followingCount, foll
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='notification__relationships-severance-event'>
|
||||
<Icon id='heart_broken' icon={HeartBrokenIcon} />
|
||||
<div role='button' className={classNames('notification-group notification-group--link notification-group--relationships-severance-event focusable', { 'notification-group--unread': unread })} tabIndex='0'>
|
||||
<div className='notification-group__icon'><Icon id='heart_broken' icon={HeartBrokenIcon} /></div>
|
||||
|
||||
<div className='notification__relationships-severance-event__content'>
|
||||
<div className='notification-group__main'>
|
||||
<p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p>
|
||||
<span className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></span>
|
||||
<a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -42,4 +44,5 @@ RelationshipsSeveranceEvent.propTypes = {
|
||||
followersCount: PropTypes.number.isRequired,
|
||||
followingCount: PropTypes.number.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
};
|
||||
|
@ -2,10 +2,13 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { initializeNotifications } from 'mastodon/actions/notifications_migration';
|
||||
|
||||
import { showAlert } from '../../../actions/alerts';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { clearNotifications } from '../../../actions/notification_groups';
|
||||
import { updateNotificationsPolicy } from '../../../actions/notification_policies';
|
||||
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
|
||||
import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
|
||||
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
|
||||
import { changeSetting } from '../../../actions/settings';
|
||||
import ColumnSettings from '../components/column_settings';
|
||||
@ -58,6 +61,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
} else {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
}
|
||||
} else if(path[0] === 'groupingBeta') {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
dispatch(initializeNotifications());
|
||||
} else {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
}
|
||||
|
@ -202,7 +202,7 @@ class Notifications extends PureComponent {
|
||||
<LoadGap
|
||||
key={'gap:' + notifications.getIn([index + 1, 'id'])}
|
||||
disabled={isLoading}
|
||||
maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
|
||||
param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
|
||||
onClick={this.handleLoadGap}
|
||||
/>
|
||||
) : (
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/@${account.acct}`}
|
||||
title={`@${account.acct}`}
|
||||
data-hover-card-account={account.id}
|
||||
>
|
||||
<Avatar account={account} size={28} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
|
||||
accountIds,
|
||||
}) => (
|
||||
<div className='notification-group__avatar-group'>
|
||||
{accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
|
||||
<AvatarWrapper key={accountId} accountId={accountId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
@ -0,0 +1,93 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||
|
||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
||||
export type Mention = RecordOf<{ url: string; acct: string }>;
|
||||
|
||||
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
statusId,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||
);
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
state.accounts.get(status?.get('account') as string),
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!account) return;
|
||||
|
||||
history.push(`/@${account.acct}/${statusId}`);
|
||||
}, [statusId, account, history]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||
const contentHtml = status.get('contentHtml') as string;
|
||||
const poll = status.get('poll');
|
||||
const language = status.get('language') as string;
|
||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
||||
const mediaAttachmentsSize = (
|
||||
status.get('media_attachments') as ImmutableList<unknown>
|
||||
).size;
|
||||
|
||||
return (
|
||||
<div className='notification-group__embedded-status'>
|
||||
<div className='notification-group__embedded-status__account'>
|
||||
<Avatar account={account} size={16} />
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
{(poll || mediaAttachmentsSize > 0) && (
|
||||
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
|
||||
{!!poll && (
|
||||
<>
|
||||
<Icon icon={BarChart4BarsIcon} id='bar-chart-4-bars' />
|
||||
<FormattedMessage
|
||||
id='reply_indicator.poll'
|
||||
defaultMessage='Poll'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{mediaAttachmentsSize > 0 && (
|
||||
<>
|
||||
<Icon icon={PhotoLibraryIcon} id='photo-library' />
|
||||
<FormattedMessage
|
||||
id='reply_indicator.attachments'
|
||||
defaultMessage='{count, plural, one {# attachment} other {# attachments}}'
|
||||
values={{ count: mediaAttachmentsSize }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,165 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type { History } from 'history';
|
||||
|
||||
import type { Mention } from './embedded_status';
|
||||
|
||||
const handleMentionClick = (
|
||||
history: History,
|
||||
mention: Mention,
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
history.push(`/@${mention.get('acct')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHashtagClick = (
|
||||
history: History,
|
||||
hashtag: string,
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
history.push(`/tags/${hashtag.replace(/^#/, '')}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const EmbeddedStatusContent: React.FC<{
|
||||
content: string;
|
||||
mentions: List<Mention>;
|
||||
language: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}> = ({ content, mentions, language, onClick, className }) => {
|
||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||
const history = useHistory();
|
||||
|
||||
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ clientX, clientY }) => {
|
||||
clickCoordinatesRef.current = [clientX, clientY];
|
||||
},
|
||||
[clickCoordinatesRef],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ clientX, clientY, target, button }) => {
|
||||
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
|
||||
const [deltaX, deltaY] = [
|
||||
Math.abs(clientX - startX),
|
||||
Math.abs(clientY - startY),
|
||||
];
|
||||
|
||||
let element: HTMLDivElement | null = target as HTMLDivElement;
|
||||
|
||||
while (element) {
|
||||
if (
|
||||
element.localName === 'button' ||
|
||||
element.localName === 'a' ||
|
||||
element.localName === 'label'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
element = element.parentNode as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && button === 0 && onClick) {
|
||||
onClick();
|
||||
}
|
||||
|
||||
clickCoordinatesRef.current = null;
|
||||
},
|
||||
[clickCoordinatesRef, onClick],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-original');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-static');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = node.querySelectorAll<HTMLAnchorElement>('a');
|
||||
|
||||
for (const link of links) {
|
||||
if (link.classList.contains('status-link')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
link.classList.add('status-link');
|
||||
|
||||
const mention = mentions.find((item) => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener(
|
||||
'click',
|
||||
handleMentionClick.bind(null, history, mention),
|
||||
false,
|
||||
);
|
||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
||||
} else if (
|
||||
link.textContent?.[0] === '#' ||
|
||||
link.previousSibling?.textContent?.endsWith('#')
|
||||
) {
|
||||
link.addEventListener(
|
||||
'click',
|
||||
handleHashtagClick.bind(null, history, link.text),
|
||||
false,
|
||||
);
|
||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.classList.add('unhandled-link');
|
||||
}
|
||||
}
|
||||
},
|
||||
[mentions, history],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={className}
|
||||
ref={handleContentRef}
|
||||
lang={language}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,132 @@
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import type { NotificationGroupAdminReport } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
// This needs to be kept in sync with app/models/report.rb
|
||||
const messages = defineMessages({
|
||||
other: {
|
||||
id: 'report_notification.categories.other_sentence',
|
||||
defaultMessage: 'other',
|
||||
},
|
||||
spam: {
|
||||
id: 'report_notification.categories.spam_sentence',
|
||||
defaultMessage: 'spam',
|
||||
},
|
||||
legal: {
|
||||
id: 'report_notification.categories.legal_sentence',
|
||||
defaultMessage: 'illegal content',
|
||||
},
|
||||
violation: {
|
||||
id: 'report_notification.categories.violation_sentence',
|
||||
defaultMessage: 'rule violation',
|
||||
},
|
||||
});
|
||||
|
||||
export const NotificationAdminReport: React.FC<{
|
||||
notification: NotificationGroupAdminReport;
|
||||
unread?: boolean;
|
||||
}> = ({ notification, notification: { report }, unread }) => {
|
||||
const intl = useIntl();
|
||||
const targetAccount = useAppSelector((state) =>
|
||||
state.accounts.get(report.targetAccountId),
|
||||
);
|
||||
const account = useAppSelector((state) =>
|
||||
state.accounts.get(notification.sampleAccountIds[0] ?? '0'),
|
||||
);
|
||||
|
||||
if (!account || !targetAccount) return null;
|
||||
|
||||
const values = {
|
||||
name: (
|
||||
<bdi
|
||||
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
|
||||
/>
|
||||
),
|
||||
target: (
|
||||
<bdi
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: targetAccount.get('display_name_html'),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
category: intl.formatMessage(messages[report.category]),
|
||||
count: report.status_ids.length,
|
||||
};
|
||||
|
||||
let message;
|
||||
|
||||
if (report.status_ids.length > 0) {
|
||||
if (report.category === 'other') {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='notification.admin.report_account_other'
|
||||
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target}'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='notification.admin.report_account'
|
||||
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (report.category === 'other') {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='notification.admin.report_statuses_other'
|
||||
defaultMessage='{name} reported {target}'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='notification.admin.report_statuses'
|
||||
defaultMessage='{name} reported {target} for {category}'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/admin/reports/${report.id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={classNames(
|
||||
'notification-group notification-group--link notification-group--admin-report focusable',
|
||||
{ 'notification-group--unread': unread },
|
||||
)}
|
||||
>
|
||||
<div className='notification-group__icon'>
|
||||
<Icon id='flag' icon={FlagIcon} />
|
||||
</div>
|
||||
|
||||
<div className='notification-group__main'>
|
||||
<div className='notification-group__main__header'>
|
||||
<div className='notification-group__main__header__label'>
|
||||
{message}
|
||||
<RelativeTimestamp timestamp={report.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.comment.length > 0 && (
|
||||
<div className='notification-group__embedded-status__content'>
|
||||
“{report.comment}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.admin.sign_up'
|
||||
defaultMessage='{name} signed up'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationAdminSignUp: React.FC<{
|
||||
notification: NotificationGroupAdminSignUp;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationGroupWithStatus
|
||||
type='admin-sign-up'
|
||||
icon={PersonAddIcon}
|
||||
iconId='person-add'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,45 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import type { NotificationGroupFavourite } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.favourite'
|
||||
defaultMessage='{name} favorited your status'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationFavourite: React.FC<{
|
||||
notification: NotificationGroupFavourite;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const { statusId } = notification;
|
||||
const statusAccount = useAppSelector(
|
||||
(state) =>
|
||||
state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
|
||||
?.acct,
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationGroupWithStatus
|
||||
type='favourite'
|
||||
icon={StarIcon}
|
||||
iconId='star'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
statusId={notification.statusId}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
labelSeeMoreHref={
|
||||
statusAccount ? `/@${statusAccount}/${statusId}/favourites` : undefined
|
||||
}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import type { NotificationGroupFollow } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.follow'
|
||||
defaultMessage='{name} followed you'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationFollow: React.FC<{
|
||||
notification: NotificationGroupFollow;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationGroupWithStatus
|
||||
type='follow'
|
||||
icon={PersonAddIcon}
|
||||
iconId='person-add'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,78 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import {
|
||||
authorizeFollowRequest,
|
||||
rejectFollowRequest,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import type { NotificationGroupFollowRequest } from 'mastodon/models/notification_group';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
|
||||
});
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.follow_request'
|
||||
defaultMessage='{name} has requested to follow you'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationFollowRequest: React.FC<{
|
||||
notification: NotificationGroupFollowRequest;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onAuthorize = useCallback(() => {
|
||||
dispatch(authorizeFollowRequest(notification.sampleAccountIds[0]));
|
||||
}, [dispatch, notification.sampleAccountIds]);
|
||||
|
||||
const onReject = useCallback(() => {
|
||||
dispatch(rejectFollowRequest(notification.sampleAccountIds[0]));
|
||||
}, [dispatch, notification.sampleAccountIds]);
|
||||
|
||||
const actions = (
|
||||
<div className='notification-group__actions'>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.reject)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onReject}
|
||||
/>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.authorize)}
|
||||
icon='check'
|
||||
iconComponent={CheckIcon}
|
||||
onClick={onAuthorize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationGroupWithStatus
|
||||
type='follow-request'
|
||||
icon={PersonAddIcon}
|
||||
iconId='person-add'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
actions={actions}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,134 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { NotificationAdminReport } from './notification_admin_report';
|
||||
import { NotificationAdminSignUp } from './notification_admin_sign_up';
|
||||
import { NotificationFavourite } from './notification_favourite';
|
||||
import { NotificationFollow } from './notification_follow';
|
||||
import { NotificationFollowRequest } from './notification_follow_request';
|
||||
import { NotificationMention } from './notification_mention';
|
||||
import { NotificationModerationWarning } from './notification_moderation_warning';
|
||||
import { NotificationPoll } from './notification_poll';
|
||||
import { NotificationReblog } from './notification_reblog';
|
||||
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
||||
import { NotificationStatus } from './notification_status';
|
||||
import { NotificationUpdate } from './notification_update';
|
||||
|
||||
export const NotificationGroup: React.FC<{
|
||||
notificationGroupId: NotificationGroupModel['group_key'];
|
||||
unread: boolean;
|
||||
onMoveUp: (groupId: string) => void;
|
||||
onMoveDown: (groupId: string) => void;
|
||||
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
|
||||
const notificationGroup = useAppSelector((state) =>
|
||||
state.notificationGroups.groups.find(
|
||||
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||
),
|
||||
);
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
moveUp: () => {
|
||||
onMoveUp(notificationGroupId);
|
||||
},
|
||||
|
||||
moveDown: () => {
|
||||
onMoveDown(notificationGroupId);
|
||||
},
|
||||
}),
|
||||
[notificationGroupId, onMoveUp, onMoveDown],
|
||||
);
|
||||
|
||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||
|
||||
let content;
|
||||
|
||||
switch (notificationGroup.type) {
|
||||
case 'reblog':
|
||||
content = (
|
||||
<NotificationReblog unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'favourite':
|
||||
content = (
|
||||
<NotificationFavourite
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'severed_relationships':
|
||||
content = (
|
||||
<NotificationSeveredRelationships
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'mention':
|
||||
content = (
|
||||
<NotificationMention unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'follow':
|
||||
content = (
|
||||
<NotificationFollow unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'follow_request':
|
||||
content = (
|
||||
<NotificationFollowRequest
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'poll':
|
||||
content = (
|
||||
<NotificationPoll unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'status':
|
||||
content = (
|
||||
<NotificationStatus unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'update':
|
||||
content = (
|
||||
<NotificationUpdate unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'admin.sign_up':
|
||||
content = (
|
||||
<NotificationAdminSignUp
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'admin.report':
|
||||
content = (
|
||||
<NotificationAdminReport
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'moderation_warning':
|
||||
content = (
|
||||
<NotificationModerationWarning
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HotKeys handlers={handlers}>{content}</HotKeys>;
|
||||
};
|
@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
import { AvatarGroup } from './avatar_group';
|
||||
import { EmbeddedStatus } from './embedded_status';
|
||||
import { NamesList } from './names_list';
|
||||
|
||||
export type LabelRenderer = (
|
||||
values: Record<string, React.ReactNode>,
|
||||
) => JSX.Element;
|
||||
|
||||
export const NotificationGroupWithStatus: React.FC<{
|
||||
icon: IconProp;
|
||||
iconId: string;
|
||||
statusId?: string;
|
||||
actions?: JSX.Element;
|
||||
count: number;
|
||||
accountIds: string[];
|
||||
timestamp: string;
|
||||
labelRenderer: LabelRenderer;
|
||||
labelSeeMoreHref?: string;
|
||||
type: string;
|
||||
unread: boolean;
|
||||
}> = ({
|
||||
icon,
|
||||
iconId,
|
||||
timestamp,
|
||||
accountIds,
|
||||
actions,
|
||||
count,
|
||||
statusId,
|
||||
labelRenderer,
|
||||
labelSeeMoreHref,
|
||||
type,
|
||||
unread,
|
||||
}) => {
|
||||
const label = useMemo(
|
||||
() =>
|
||||
labelRenderer({
|
||||
name: (
|
||||
<NamesList
|
||||
accountIds={accountIds}
|
||||
total={count}
|
||||
seeMoreHref={labelSeeMoreHref}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[labelRenderer, accountIds, count, labelSeeMoreHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
className={classNames(
|
||||
`notification-group focusable notification-group--${type}`,
|
||||
{ 'notification-group--unread': unread },
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className='notification-group__icon'>
|
||||
<Icon icon={icon} id={iconId} />
|
||||
</div>
|
||||
|
||||
<div className='notification-group__main'>
|
||||
<div className='notification-group__main__header'>
|
||||
<div className='notification-group__main__header__wrapper'>
|
||||
<AvatarGroup accountIds={accountIds} />
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
<div className='notification-group__main__header__label'>
|
||||
{label}
|
||||
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusId && (
|
||||
<div className='notification-group__main__status'>
|
||||
<EmbeddedStatus statusId={statusId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import type { StatusVisibility } from 'mastodon/api_types/statuses';
|
||||
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.mention'
|
||||
defaultMessage='{name} mentioned you'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.private_mention'
|
||||
defaultMessage='{name} privately mentioned you'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationMention: React.FC<{
|
||||
notification: NotificationGroupMention;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const statusVisibility = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn([
|
||||
notification.statusId,
|
||||
'visibility',
|
||||
]) as StatusVisibility,
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationWithStatus
|
||||
type='mention'
|
||||
icon={ReplyIcon}
|
||||
iconId='reply'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
statusVisibility === 'direct'
|
||||
? privateMentionLabelRenderer
|
||||
: labelRenderer
|
||||
}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import { ModerationWarning } from 'mastodon/features/notifications/components/moderation_warning';
|
||||
import type { NotificationGroupModerationWarning } from 'mastodon/models/notification_group';
|
||||
|
||||
export const NotificationModerationWarning: React.FC<{
|
||||
notification: NotificationGroupModerationWarning;
|
||||
unread: boolean;
|
||||
}> = ({ notification: { moderationWarning }, unread }) => (
|
||||
<ModerationWarning
|
||||
action={moderationWarning.action}
|
||||
id={moderationWarning.id}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,41 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { NotificationGroupPoll } from 'mastodon/models/notification_group';
|
||||
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRendererOther = () => (
|
||||
<FormattedMessage
|
||||
id='notification.poll'
|
||||
defaultMessage='A poll you voted in has ended'
|
||||
/>
|
||||
);
|
||||
|
||||
const labelRendererOwn = () => (
|
||||
<FormattedMessage
|
||||
id='notification.own_poll'
|
||||
defaultMessage='Your poll has ended'
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationPoll: React.FC<{
|
||||
notification: NotificationGroupPoll;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationWithStatus
|
||||
type='poll'
|
||||
icon={BarChart4BarsIcon}
|
||||
iconId='bar-chart-4-bars'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
notification.sampleAccountIds[0] === me
|
||||
? labelRendererOwn
|
||||
: labelRendererOther
|
||||
}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,45 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.reblog'
|
||||
defaultMessage='{name} boosted your status'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationReblog: React.FC<{
|
||||
notification: NotificationGroupReblog;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const { statusId } = notification;
|
||||
const statusAccount = useAppSelector(
|
||||
(state) =>
|
||||
state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
|
||||
?.acct,
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationGroupWithStatus
|
||||
type='reblog'
|
||||
icon={RepeatIcon}
|
||||
iconId='repeat'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
statusId={notification.statusId}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
labelSeeMoreHref={
|
||||
statusAccount ? `/@${statusAccount}/${statusId}/reblogs` : undefined
|
||||
}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { RelationshipsSeveranceEvent } from 'mastodon/features/notifications/components/relationships_severance_event';
|
||||
import type { NotificationGroupSeveredRelationships } from 'mastodon/models/notification_group';
|
||||
|
||||
export const NotificationSeveredRelationships: React.FC<{
|
||||
notification: NotificationGroupSeveredRelationships;
|
||||
unread: boolean;
|
||||
}> = ({ notification: { event }, unread }) => (
|
||||
<RelationshipsSeveranceEvent
|
||||
type={event.type}
|
||||
target={event.target_name}
|
||||
followersCount={event.followers_count}
|
||||
followingCount={event.following_count}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,31 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import type { NotificationGroupStatus } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.status'
|
||||
defaultMessage='{name} just posted'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationStatus: React.FC<{
|
||||
notification: NotificationGroupStatus;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationWithStatus
|
||||
type='status'
|
||||
icon={NotificationsActiveIcon}
|
||||
iconId='notifications-active'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,31 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import type { NotificationGroupUpdate } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
<FormattedMessage
|
||||
id='notification.update'
|
||||
defaultMessage='{name} edited a post'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationUpdate: React.FC<{
|
||||
notification: NotificationGroupUpdate;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationWithStatus
|
||||
type='update'
|
||||
icon={EditIcon}
|
||||
iconId='edit'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import Status from 'mastodon/containers/status_container';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { NamesList } from './names_list';
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
|
||||
export const NotificationWithStatus: React.FC<{
|
||||
type: string;
|
||||
icon: IconProp;
|
||||
iconId: string;
|
||||
accountIds: string[];
|
||||
statusId: string;
|
||||
count: number;
|
||||
labelRenderer: LabelRenderer;
|
||||
unread: boolean;
|
||||
}> = ({
|
||||
icon,
|
||||
iconId,
|
||||
accountIds,
|
||||
statusId,
|
||||
count,
|
||||
labelRenderer,
|
||||
type,
|
||||
unread,
|
||||
}) => {
|
||||
const label = useMemo(
|
||||
() =>
|
||||
labelRenderer({
|
||||
name: <NamesList accountIds={accountIds} total={count} />,
|
||||
}),
|
||||
[labelRenderer, accountIds, count],
|
||||
);
|
||||
|
||||
const isPrivateMention = useAppSelector(
|
||||
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
className={classNames(
|
||||
`notification-ungrouped focusable notification-ungrouped--${type}`,
|
||||
{
|
||||
'notification-ungrouped--unread': unread,
|
||||
'notification-ungrouped--direct': isPrivateMention,
|
||||
},
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className='notification-ungrouped__header'>
|
||||
<div className='notification-ungrouped__header__icon'>
|
||||
<Icon icon={icon} id={iconId} />
|
||||
</div>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<Status
|
||||
// @ts-expect-error -- <Status> is not yet typed
|
||||
id={statusId}
|
||||
contextType='notifications'
|
||||
withDismiss
|
||||
skipPrepend
|
||||
avatarSize={40}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
145
app/javascript/mastodon/features/notifications_v2/filter_bar.tsx
Normal file
145
app/javascript/mastodon/features/notifications_v2/filter_bar.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { setNotificationsFilter } from 'mastodon/actions/notification_groups';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import {
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterAdvanced,
|
||||
} from 'mastodon/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: {
|
||||
id: 'notifications.filter.favourites',
|
||||
defaultMessage: 'Favorites',
|
||||
},
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
statuses: {
|
||||
id: 'notifications.filter.statuses',
|
||||
defaultMessage: 'Updates from people you follow',
|
||||
},
|
||||
});
|
||||
|
||||
const BarButton: React.FC<
|
||||
PropsWithChildren<{
|
||||
selectedFilter: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
}>
|
||||
> = ({ selectedFilter, type, title, children }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
void dispatch(setNotificationsFilter({ filterType: type }));
|
||||
}, [dispatch, type]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={selectedFilter === type ? 'active' : ''}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterBar: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const selectedFilter = useAppSelector(
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
);
|
||||
const advancedMode = useAppSelector(
|
||||
selectSettingsNotificationsQuickFilterAdvanced,
|
||||
);
|
||||
|
||||
if (advancedMode)
|
||||
return (
|
||||
<div className='notification__filter-bar'>
|
||||
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='mention'
|
||||
key='mention'
|
||||
title={intl.formatMessage(tooltips.mentions)}
|
||||
>
|
||||
<Icon id='reply-all' icon={ReplyAllIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='favourite'
|
||||
key='favourite'
|
||||
title={intl.formatMessage(tooltips.favourites)}
|
||||
>
|
||||
<Icon id='star' icon={StarIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='reblog'
|
||||
key='reblog'
|
||||
title={intl.formatMessage(tooltips.boosts)}
|
||||
>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='poll'
|
||||
key='poll'
|
||||
title={intl.formatMessage(tooltips.polls)}
|
||||
>
|
||||
<Icon id='tasks' icon={InsertChartIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='status'
|
||||
key='status'
|
||||
title={intl.formatMessage(tooltips.statuses)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='follow'
|
||||
key='follow'
|
||||
title={intl.formatMessage(tooltips.follows)}
|
||||
>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
</BarButton>
|
||||
</div>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<div className='notification__filter-bar'>
|
||||
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</BarButton>
|
||||
<BarButton selectedFilter={selectedFilter} type='mention' key='mention'>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.mentions'
|
||||
defaultMessage='Mentions'
|
||||
/>
|
||||
</BarButton>
|
||||
</div>
|
||||
);
|
||||
};
|
354
app/javascript/mastodon/features/notifications_v2/index.tsx
Normal file
354
app/javascript/mastodon/features/notifications_v2/index.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import {
|
||||
fetchNotificationsGap,
|
||||
updateScrollPosition,
|
||||
loadPending,
|
||||
markNotificationsAsRead,
|
||||
mountNotifications,
|
||||
unmountNotifications,
|
||||
} from 'mastodon/actions/notification_groups';
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
|
||||
import {
|
||||
selectUnreadNotificationGroupsCount,
|
||||
selectPendingNotificationGroupsCount,
|
||||
} from 'mastodon/selectors/notifications';
|
||||
import {
|
||||
selectNeedsNotificationPermission,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsShowUnread,
|
||||
} from 'mastodon/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { submitMarkers } from '../../actions/markers';
|
||||
import Column from '../../components/column';
|
||||
import { ColumnHeader } from '../../components/column_header';
|
||||
import { LoadGap } from '../../components/load_gap';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import { FilteredNotificationsBanner } from '../notifications/components/filtered_notifications_banner';
|
||||
import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner';
|
||||
import ColumnSettingsContainer from '../notifications/containers/column_settings_container';
|
||||
|
||||
import { NotificationGroup } from './components/notification_group';
|
||||
import { FilterBar } from './filter_bar';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
markAsRead: {
|
||||
id: 'notifications.mark_as_read',
|
||||
defaultMessage: 'Mark every notification as read',
|
||||
},
|
||||
});
|
||||
|
||||
const getNotifications = createSelector(
|
||||
[
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
(state: RootState) => state.notificationGroups.groups,
|
||||
],
|
||||
(showFilterBar, allowedType, excludedTypes, notifications) => {
|
||||
if (!showFilterBar || allowedType === 'all') {
|
||||
// used if user changed the notification settings after loading the notifications from the server
|
||||
// otherwise a list of notifications will come pre-filtered from the backend
|
||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||
return notifications.filter(
|
||||
(item) => item.type === 'gap' || !excludedTypes.includes(item.type),
|
||||
);
|
||||
}
|
||||
return notifications.filter(
|
||||
(item) => item.type === 'gap' || allowedType === item.type,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Notifications: React.FC<{
|
||||
columnId?: string;
|
||||
multiColumn?: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const notifications = useAppSelector(getNotifications);
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||
|
||||
const lastReadId = useAppSelector((s) =>
|
||||
selectSettingsNotificationsShowUnread(s)
|
||||
? s.notificationGroups.lastReadId
|
||||
: '0',
|
||||
);
|
||||
|
||||
const numPending = useAppSelector(selectPendingNotificationGroupsCount);
|
||||
|
||||
const unreadNotificationsCount = useAppSelector(
|
||||
selectUnreadNotificationGroupsCount,
|
||||
);
|
||||
|
||||
const isUnread = unreadNotificationsCount > 0;
|
||||
|
||||
const canMarkAsRead =
|
||||
useAppSelector(selectSettingsNotificationsShowUnread) &&
|
||||
unreadNotificationsCount > 0;
|
||||
|
||||
const needsNotificationPermission = useAppSelector(
|
||||
selectNeedsNotificationPermission,
|
||||
);
|
||||
|
||||
const columnRef = useRef<Column>(null);
|
||||
|
||||
const selectChild = useCallback((index: number, alignTop: boolean) => {
|
||||
const container = columnRef.current?.node as HTMLElement | undefined;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const element = container.querySelector<HTMLElement>(
|
||||
`article:nth-of-type(${index + 1}) .focusable`,
|
||||
);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (
|
||||
!alignTop &&
|
||||
container.scrollTop + container.clientHeight <
|
||||
element.offsetTop + element.offsetHeight
|
||||
) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep track of mounted components for unread notification handling
|
||||
useEffect(() => {
|
||||
dispatch(mountNotifications());
|
||||
|
||||
return () => {
|
||||
dispatch(unmountNotifications());
|
||||
dispatch(updateScrollPosition({ top: false }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadGap = useCallback(
|
||||
(gap: NotificationGap) => {
|
||||
void dispatch(fetchNotificationsGap({ gap }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLoadOlder = useDebouncedCallback(
|
||||
() => {
|
||||
const gap = notifications.at(-1);
|
||||
if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap }));
|
||||
},
|
||||
300,
|
||||
{ leading: true },
|
||||
);
|
||||
|
||||
const handleLoadPending = useCallback(() => {
|
||||
dispatch(loadPending());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleScrollToTop = useDebouncedCallback(() => {
|
||||
dispatch(updateScrollPosition({ top: true }));
|
||||
}, 100);
|
||||
|
||||
const handleScroll = useDebouncedCallback(() => {
|
||||
dispatch(updateScrollPosition({ top: false }));
|
||||
}, 100);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleLoadOlder.cancel();
|
||||
handleScrollToTop.cancel();
|
||||
handleScroll.cancel();
|
||||
};
|
||||
}, [handleLoadOlder, handleScrollToTop, handleScroll]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('NOTIFICATIONS', {}));
|
||||
}
|
||||
}, [columnId, dispatch]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: unknown) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleMoveUp = useCallback(
|
||||
(id: string) => {
|
||||
const elementIndex =
|
||||
notifications.findIndex(
|
||||
(item) => item.type !== 'gap' && item.group_key === id,
|
||||
) - 1;
|
||||
selectChild(elementIndex, true);
|
||||
},
|
||||
[notifications, selectChild],
|
||||
);
|
||||
|
||||
const handleMoveDown = useCallback(
|
||||
(id: string) => {
|
||||
const elementIndex =
|
||||
notifications.findIndex(
|
||||
(item) => item.type !== 'gap' && item.group_key === id,
|
||||
) + 1;
|
||||
selectChild(elementIndex, false);
|
||||
},
|
||||
[notifications, selectChild],
|
||||
);
|
||||
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
dispatch(markNotificationsAsRead());
|
||||
void dispatch(submitMarkers({ immediate: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.notifications'
|
||||
defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here."
|
||||
/>
|
||||
);
|
||||
|
||||
const { signedIn } = useIdentity();
|
||||
|
||||
const filterBar = signedIn ? <FilterBar /> : null;
|
||||
|
||||
const scrollableContent = useMemo(() => {
|
||||
if (notifications.length === 0 && !hasMore) return null;
|
||||
|
||||
return notifications.map((item) =>
|
||||
item.type === 'gap' ? (
|
||||
<LoadGap
|
||||
key={`${item.maxId}-${item.sinceId}`}
|
||||
disabled={isLoading}
|
||||
param={item}
|
||||
onClick={handleLoadGap}
|
||||
/>
|
||||
) : (
|
||||
<NotificationGroup
|
||||
key={item.group_key}
|
||||
notificationGroupId={item.group_key}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
unread={
|
||||
lastReadId !== '0' &&
|
||||
!!item.page_max_id &&
|
||||
compareId(item.page_max_id, lastReadId) > 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}, [
|
||||
notifications,
|
||||
isLoading,
|
||||
hasMore,
|
||||
lastReadId,
|
||||
handleLoadGap,
|
||||
handleMoveUp,
|
||||
handleMoveDown,
|
||||
]);
|
||||
|
||||
const prepend = (
|
||||
<>
|
||||
{needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||
<FilteredNotificationsBanner />
|
||||
</>
|
||||
);
|
||||
|
||||
const scrollContainer = signedIn ? (
|
||||
<ScrollableList
|
||||
scrollKey={`notifications-${columnId}`}
|
||||
trackScroll={!pinned}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.length === 0}
|
||||
hasMore={hasMore}
|
||||
numPending={numPending}
|
||||
prepend={prepend}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
onLoadMore={handleLoadOlder}
|
||||
onLoadPending={handleLoadPending}
|
||||
onScrollToTop={handleScrollToTop}
|
||||
onScroll={handleScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
) : (
|
||||
<NotSignedInIndicator />
|
||||
);
|
||||
|
||||
const extraButton = canMarkAsRead ? (
|
||||
<button
|
||||
aria-label={intl.formatMessage(messages.markAsRead)}
|
||||
title={intl.formatMessage(messages.markAsRead)}
|
||||
onClick={handleMarkAsRead}
|
||||
className='column-header__button'
|
||||
>
|
||||
<Icon id='done-all' icon={DoneAllIcon} />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bell'
|
||||
iconComponent={NotificationsIcon}
|
||||
active={isUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={extraButton}
|
||||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
{filterBar}
|
||||
|
||||
{scrollContainer}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Notifications;
|
13
app/javascript/mastodon/features/notifications_wrapper.jsx
Normal file
13
app/javascript/mastodon/features/notifications_wrapper.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import Notifications from 'mastodon/features/notifications';
|
||||
import Notifications_v2 from 'mastodon/features/notifications_v2';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const NotificationsWrapper = (props) => {
|
||||
const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
|
||||
|
||||
return (
|
||||
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWrapper;
|
@ -10,7 +10,7 @@ import { scrollRight } from '../../../scroll';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import {
|
||||
Compose,
|
||||
Notifications,
|
||||
NotificationsWrapper,
|
||||
HomeTimeline,
|
||||
CommunityTimeline,
|
||||
PublicTimeline,
|
||||
@ -32,7 +32,7 @@ import NavigationPanel from './navigation_panel';
|
||||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
'HOME': HomeTimeline,
|
||||
'NOTIFICATIONS': Notifications,
|
||||
'NOTIFICATIONS': NotificationsWrapper,
|
||||
'PUBLIC': PublicTimeline,
|
||||
'REMOTE': PublicTimeline,
|
||||
'COMMUNITY': CommunityTimeline,
|
||||
|
@ -34,6 +34,7 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
|
||||
import { transientSingleColumn } from 'mastodon/is_mobile';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
|
||||
import ColumnLink from './column_link';
|
||||
import DisabledAccountBanner from './disabled_account_banner';
|
||||
@ -59,15 +60,19 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const NotificationsLink = () => {
|
||||
const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
|
||||
const count = useSelector(state => state.getIn(['notifications', 'unread']));
|
||||
const intl = useIntl();
|
||||
|
||||
const newCount = useSelector(selectUnreadNotificationGroupsCount);
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
key='notifications'
|
||||
transparent
|
||||
to='/notifications'
|
||||
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />}
|
||||
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />}
|
||||
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
|
||||
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
|
||||
text={intl.formatMessage(messages.notifications)}
|
||||
/>
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||
import { initializeNotifications } from 'mastodon/actions/notifications_migration';
|
||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
||||
@ -22,7 +23,6 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { expandNotifications } from '../../actions/notifications';
|
||||
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
|
||||
@ -49,7 +49,7 @@ import {
|
||||
Favourites,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
Notifications,
|
||||
NotificationsWrapper,
|
||||
NotificationRequests,
|
||||
NotificationRequest,
|
||||
FollowRequests,
|
||||
@ -71,6 +71,7 @@ import {
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
|
||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||
// Without this it ends up in ~8 very commonly used bundles.
|
||||
import '../../components/status';
|
||||
@ -205,7 +206,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
|
||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
@ -405,7 +406,7 @@ class UI extends PureComponent {
|
||||
if (signedIn) {
|
||||
this.props.dispatch(fetchMarkers());
|
||||
this.props.dispatch(expandHomeTimeline());
|
||||
this.props.dispatch(expandNotifications());
|
||||
this.props.dispatch(initializeNotifications());
|
||||
this.props.dispatch(fetchServerTranslationLanguages());
|
||||
|
||||
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
|
||||
|
@ -7,7 +7,15 @@ export function Compose () {
|
||||
}
|
||||
|
||||
export function Notifications () {
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
|
||||
return import(/* webpackChunkName: "features/notifications_v1" */'../../notifications');
|
||||
}
|
||||
|
||||
export function Notifications_v2 () {
|
||||
return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2');
|
||||
}
|
||||
|
||||
export function NotificationsWrapper () {
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications_wrapper');
|
||||
}
|
||||
|
||||
export function HomeTimeline () {
|
||||
|
@ -443,6 +443,8 @@
|
||||
"mute_modal.title": "Mute user?",
|
||||
"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.",
|
||||
"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.advanced_interface": "Open in advanced web interface",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
@ -470,6 +472,10 @@
|
||||
"navigation_bar.security": "Security",
|
||||
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
|
||||
"notification.admin.report": "{name} reported {target}",
|
||||
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
|
||||
"notification.admin.report_account_other": "{name} reported {count, plural, one {one post} other {# posts}} from {target}",
|
||||
"notification.admin.report_statuses": "{name} reported {target} for {category}",
|
||||
"notification.admin.report_statuses_other": "{name} reported {target}",
|
||||
"notification.admin.sign_up": "{name} signed up",
|
||||
"notification.favourite": "{name} favorited your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
@ -485,7 +491,8 @@
|
||||
"notification.moderation_warning.action_silence": "Your account has been limited.",
|
||||
"notification.moderation_warning.action_suspend": "Your account has been suspended.",
|
||||
"notification.own_poll": "Your poll has ended",
|
||||
"notification.poll": "A poll you have voted in has ended",
|
||||
"notification.poll": "A poll you voted in has ended",
|
||||
"notification.private_mention": "{name} privately mentioned you",
|
||||
"notification.reblog": "{name} boosted your post",
|
||||
"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.",
|
||||
@ -503,6 +510,8 @@
|
||||
"notifications.column_settings.admin.report": "New reports:",
|
||||
"notifications.column_settings.admin.sign_up": "New sign-ups:",
|
||||
"notifications.column_settings.alert": "Desktop notifications",
|
||||
"notifications.column_settings.beta.category": "Experimental features",
|
||||
"notifications.column_settings.beta.grouping": "Group notifications",
|
||||
"notifications.column_settings.favourite": "Favorites:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Display all categories",
|
||||
"notifications.column_settings.filter_bar.category": "Quick filter bar",
|
||||
@ -666,9 +675,13 @@
|
||||
"report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached",
|
||||
"report_notification.categories.legal": "Legal",
|
||||
"report_notification.categories.legal_sentence": "illegal content",
|
||||
"report_notification.categories.other": "Other",
|
||||
"report_notification.categories.other_sentence": "other",
|
||||
"report_notification.categories.spam": "Spam",
|
||||
"report_notification.categories.spam_sentence": "spam",
|
||||
"report_notification.categories.violation": "Rule violation",
|
||||
"report_notification.categories.violation_sentence": "rule violation",
|
||||
"report_notification.open": "Open report",
|
||||
"search.no_recent_searches": "No recent searches",
|
||||
"search.placeholder": "Search",
|
||||
|
@ -29,8 +29,8 @@
|
||||
"account.enable_notifications": "Cuir mé in eol nuair bpostálann @{name}",
|
||||
"account.endorse": "Cuir ar an phróifíl mar ghné",
|
||||
"account.featured_tags.last_status_at": "Postáil is déanaí ar {date}",
|
||||
"account.featured_tags.last_status_never": "Níl postáil ar bith ann",
|
||||
"account.featured_tags.title": "Haischlib {name}",
|
||||
"account.featured_tags.last_status_never": "Gan aon phoist",
|
||||
"account.featured_tags.title": "Haischlib faoi thrácht {name}",
|
||||
"account.follow": "Lean",
|
||||
"account.follow_back": "Leanúint ar ais",
|
||||
"account.followers": "Leantóirí",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.followers_counter": "{count, plural, one {{counter} leantóir} other {{counter} leantóirí}}",
|
||||
"account.following": "Ag leanúint",
|
||||
"account.following_counter": "{count, plural, one {{counter} ag leanúint} other {{counter} ag leanúint}}",
|
||||
"account.follows.empty": "Ní leanann an t-úsáideoir seo duine ar bith fós.",
|
||||
"account.follows.empty": "Ní leanann an t-úsáideoir seo aon duine go fóill.",
|
||||
"account.go_to_profile": "Téigh go dtí próifíl",
|
||||
"account.hide_reblogs": "Folaigh moltaí ó @{name}",
|
||||
"account.in_memoriam": "Cuimhneachán.",
|
||||
@ -46,7 +46,7 @@
|
||||
"account.languages": "Athraigh teangacha foscríofa",
|
||||
"account.link_verified_on": "Seiceáladh úinéireacht an naisc seo ar {date}",
|
||||
"account.locked_info": "Tá an socrú príobháideachais don cuntas seo curtha go 'faoi ghlas'. Déanann an t-úinéir léirmheas ar cén daoine atá ceadaithe an cuntas leanúint.",
|
||||
"account.media": "Ábhair",
|
||||
"account.media": "Meáin",
|
||||
"account.mention": "Luaigh @{name}",
|
||||
"account.moved_to": "Tá tugtha le fios ag {name} gurb é an cuntas nua atá acu ná:",
|
||||
"account.mute": "Balbhaigh @{name}",
|
||||
@ -66,7 +66,7 @@
|
||||
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} poist}}",
|
||||
"account.unblock": "Bain bac de @{name}",
|
||||
"account.unblock_domain": "Bain bac den ainm fearainn {domain}",
|
||||
"account.unblock_short": "Bain bac de",
|
||||
"account.unblock_short": "Díbhlocáil",
|
||||
"account.unendorse": "Ná chuir ar an phróifíl mar ghné",
|
||||
"account.unfollow": "Ná lean a thuilleadh",
|
||||
"account.unmute": "Díbhalbhaigh @{name}",
|
||||
@ -100,7 +100,7 @@
|
||||
"boost_modal.combo": "Is féidir leat {combo} a bhrú chun é seo a scipeáil an chéad uair eile",
|
||||
"bundle_column_error.copy_stacktrace": "Cóipeáil tuairisc earráide",
|
||||
"bundle_column_error.error.body": "Ní féidir an leathanach a iarradh a sholáthar. Seans gurb amhlaidh mar gheall ar fhabht sa chód, nó mar gheall ar mhíréireacht leis an mbrabhsálaí.",
|
||||
"bundle_column_error.error.title": "Ná habair!",
|
||||
"bundle_column_error.error.title": "Ó, níl sé sin go maith!",
|
||||
"bundle_column_error.network.body": "Tharla earráid agus an leathanach á lódáil. Seans gur mar gheall ar fhadhb shealadach le do nasc idirlín nó i ndáil leis an bhfreastalaí seo atá sé.",
|
||||
"bundle_column_error.network.title": "Earráid líonra",
|
||||
"bundle_column_error.retry": "Bain triail as arís",
|
||||
@ -135,9 +135,9 @@
|
||||
"column_header.hide_settings": "Folaigh socruithe",
|
||||
"column_header.moveLeft_settings": "Bog an colún ar chlé",
|
||||
"column_header.moveRight_settings": "Bog an colún ar dheis",
|
||||
"column_header.pin": "Greamaigh",
|
||||
"column_header.pin": "Pionna",
|
||||
"column_header.show_settings": "Taispeáin socruithe",
|
||||
"column_header.unpin": "Díghreamaigh",
|
||||
"column_header.unpin": "Bain pionna",
|
||||
"column_subheading.settings": "Socruithe",
|
||||
"community.column_settings.local_only": "Áitiúil amháin",
|
||||
"community.column_settings.media_only": "Meáin Amháin",
|
||||
@ -161,7 +161,7 @@
|
||||
"compose_form.poll.switch_to_single": "Athraigh suirbhé chun cead a thabhairt do rogha amháin",
|
||||
"compose_form.poll.type": "Stíl",
|
||||
"compose_form.publish": "Postáil",
|
||||
"compose_form.publish_form": "Foilsigh\n",
|
||||
"compose_form.publish_form": "Post nua",
|
||||
"compose_form.reply": "Freagra",
|
||||
"compose_form.save_changes": "Nuashonrú",
|
||||
"compose_form.spoiler.marked": "Bain rabhadh ábhair",
|
||||
@ -291,7 +291,7 @@
|
||||
"filter_modal.added.short_explanation": "Cuireadh an postáil seo leis an gcatagóir scagaire seo a leanas: {title}.",
|
||||
"filter_modal.added.title": "Scagaire curtha leis!",
|
||||
"filter_modal.select_filter.context_mismatch": "ní bhaineann sé leis an gcomhthéacs seo",
|
||||
"filter_modal.select_filter.expired": "as feidhm",
|
||||
"filter_modal.select_filter.expired": "imithe in éag",
|
||||
"filter_modal.select_filter.prompt_new": "Catagóir nua: {name}",
|
||||
"filter_modal.select_filter.search": "Cuardaigh nó cruthaigh",
|
||||
"filter_modal.select_filter.subtitle": "Bain úsáid as catagóir reatha nó cruthaigh ceann nua",
|
||||
@ -377,7 +377,7 @@
|
||||
"keyboard_shortcuts.boost": "Treisigh postáil",
|
||||
"keyboard_shortcuts.column": "to focus a status in one of the columns",
|
||||
"keyboard_shortcuts.compose": "to focus the compose textarea",
|
||||
"keyboard_shortcuts.description": "Cuntas",
|
||||
"keyboard_shortcuts.description": "Cur síos",
|
||||
"keyboard_shortcuts.direct": "to open direct messages column",
|
||||
"keyboard_shortcuts.down": "Bog síos ar an liosta",
|
||||
"keyboard_shortcuts.enter": "Oscail postáil",
|
||||
@ -394,17 +394,17 @@
|
||||
"keyboard_shortcuts.my_profile": "Oscail do phróifíl",
|
||||
"keyboard_shortcuts.notifications": "to open notifications column",
|
||||
"keyboard_shortcuts.open_media": "Oscail meáin",
|
||||
"keyboard_shortcuts.pinned": "to open pinned posts list",
|
||||
"keyboard_shortcuts.pinned": "Oscail liosta postálacha pinn",
|
||||
"keyboard_shortcuts.profile": "Oscail próifíl an t-údar",
|
||||
"keyboard_shortcuts.reply": "Freagair ar phostáil",
|
||||
"keyboard_shortcuts.requests": "Oscail liosta iarratas leanúnaí",
|
||||
"keyboard_shortcuts.search": "to focus search",
|
||||
"keyboard_shortcuts.spoilers": "to show/hide CW field",
|
||||
"keyboard_shortcuts.start": "to open \"get started\" column",
|
||||
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
|
||||
"keyboard_shortcuts.search": "Díriú ar an mbosca cuardaigh",
|
||||
"keyboard_shortcuts.spoilers": "Taispeáin / folaigh réimse CW",
|
||||
"keyboard_shortcuts.start": "Oscail an colún “tosaigh”",
|
||||
"keyboard_shortcuts.toggle_hidden": "Taispeáin/folaigh an téacs taobh thiar de CW",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "Taispeáin / cuir i bhfolach meáin",
|
||||
"keyboard_shortcuts.toot": "Cuir tús le postáil nua",
|
||||
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
|
||||
"keyboard_shortcuts.unfocus": "Unfocus cum textarea/search",
|
||||
"keyboard_shortcuts.up": "Bog suas ar an liosta",
|
||||
"lightbox.close": "Dún",
|
||||
"lightbox.compress": "Comhbhrúigh an bosca amhairc íomhá",
|
||||
@ -545,12 +545,12 @@
|
||||
"notifications_permission_banner.title": "Ná caill aon rud go deo",
|
||||
"onboarding.action.back": "Tóg ar ais mé",
|
||||
"onboarding.actions.back": "Tóg ar ais mé",
|
||||
"onboarding.actions.go_to_explore": "See what's trending",
|
||||
"onboarding.actions.go_to_home": "Go to your home feed",
|
||||
"onboarding.actions.go_to_explore": "Tóg mé chun trending",
|
||||
"onboarding.actions.go_to_home": "Tóg go dtí mo bheathú baile mé",
|
||||
"onboarding.compose.template": "Dia duit #Mastodon!",
|
||||
"onboarding.follows.empty": "Ar an drochuair, ní féidir aon torthaí a thaispeáint faoi láthair. Is féidir leat triail a bhaint as cuardach nó brabhsáil ar an leathanach taiscéalaíochta chun teacht ar dhaoine le leanúint, nó bain triail eile as níos déanaí.",
|
||||
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
|
||||
"onboarding.follows.title": "Popular on Mastodon",
|
||||
"onboarding.follows.lead": "Is é do bheathú baile an príomhbhealach chun taithí a fháil ar Mastodon. Dá mhéad daoine a leanann tú, is ea is gníomhaí agus is suimiúla a bheidh sé. Chun tú a chur ar bun, seo roinnt moltaí:",
|
||||
"onboarding.follows.title": "Cuir do chuid fotha baile in oiriúint duit féin",
|
||||
"onboarding.profile.discoverable": "Déan mo phróifíl a fháil amach",
|
||||
"onboarding.profile.discoverable_hint": "Nuair a roghnaíonn tú infhionnachtana ar Mastodon, d’fhéadfadh do phoist a bheith le feiceáil i dtorthaí cuardaigh agus treochtaí, agus d’fhéadfaí do phróifíl a mholadh do dhaoine a bhfuil na leasanna céanna acu leat.",
|
||||
"onboarding.profile.display_name": "Ainm taispeána",
|
||||
@ -566,17 +566,17 @@
|
||||
"onboarding.share.message": "Is {username} mé ar #Mastodon! Tar lean mé ag {url}",
|
||||
"onboarding.share.next_steps": "Na chéad chéimeanna eile is féidir:",
|
||||
"onboarding.share.title": "Roinn do phróifíl",
|
||||
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
|
||||
"onboarding.start.skip": "Want to skip right ahead?",
|
||||
"onboarding.start.lead": "Tá tú mar chuid de Mastodon anois, ardán meán sóisialta díláraithe uathúil ina ndéanann tú - ní algartam - do thaithí féin a choimeád. Cuirimis tús leat ar an teorainn shóisialta nua seo:",
|
||||
"onboarding.start.skip": "Nach bhfuil cabhair uait le tosú?",
|
||||
"onboarding.start.title": "Tá sé déanta agat!",
|
||||
"onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.",
|
||||
"onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}",
|
||||
"onboarding.steps.publish_status.body": "Say hello to the world.",
|
||||
"onboarding.steps.follow_people.body": "Is éard atá i gceist le daoine suimiúla a leanúint ná Mastodon.",
|
||||
"onboarding.steps.follow_people.title": "Cuir do chuid fotha baile in oiriúint duit féin",
|
||||
"onboarding.steps.publish_status.body": "Abair heileo leis an domhan le téacs, grianghraif, físeáin nó pobalbhreith {emoji}",
|
||||
"onboarding.steps.publish_status.title": "Déan do chéad phostáil",
|
||||
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
|
||||
"onboarding.steps.setup_profile.title": "Customize your profile",
|
||||
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!",
|
||||
"onboarding.steps.share_profile.title": "Share your profile",
|
||||
"onboarding.steps.setup_profile.body": "Cuir le d'idirghníomhaíochtaí trí phróifíl chuimsitheach a bheith agat.",
|
||||
"onboarding.steps.setup_profile.title": "Déan do phróifíl a phearsantú",
|
||||
"onboarding.steps.share_profile.body": "Cuir in iúl do do chairde conas tú a aimsiú ar Mastodon",
|
||||
"onboarding.steps.share_profile.title": "Roinn do phróifíl Mastodon",
|
||||
"onboarding.tips.2fa": "<strong>An raibh a fhios agat?</strong> Is féidir leat do chuntas a dhéanamh slán trí fhíordheimhniú dhá fhachtóir a shocrú i socruithe do chuntais. Oibríonn sé le haon aip TOTP de do rogha féin, níl aon uimhir theileafóin riachtanach!",
|
||||
"onboarding.tips.accounts_from_other_servers": "<strong>An raibh a fhios agat?</strong> Ós rud é go bhfuil Mastodon díláraithe, déanfar roinnt próifílí a dtagann tú trasna orthu a óstáil ar fhreastalaithe seachas do fhreastalaithe. Agus fós is féidir leat idirghníomhú leo gan uaim! Tá an freastalaí acu sa dara leath dá n-ainm úsáideora!",
|
||||
"onboarding.tips.migration": "<strong>An raibh a fhios agat?</strong> Más dóigh leat nach rogha freastalaí iontach é {domain} amach anseo, is féidir leat bogadh go freastalaí Mastodon eile gan do leantóirí a chailliúint. Is féidir leat do fhreastalaí féin a óstáil fiú!",
|
||||
@ -594,7 +594,7 @@
|
||||
"poll.votes": "{votes, plural, one {# vóta} other {# vóta}}",
|
||||
"poll_button.add_poll": "Cruthaigh suirbhé",
|
||||
"poll_button.remove_poll": "Bain suirbhé",
|
||||
"privacy.change": "Adjust status privacy",
|
||||
"privacy.change": "Athraigh príobháideacht postála",
|
||||
"privacy.direct.long": "Luaigh gach duine sa phost",
|
||||
"privacy.direct.short": "Daoine ar leith",
|
||||
"privacy.private.long": "Do leanúna amháin",
|
||||
@ -687,8 +687,8 @@
|
||||
"search_popout.specific_date": "dáta ar leith",
|
||||
"search_popout.user": "úsáideoir",
|
||||
"search_results.accounts": "Próifílí",
|
||||
"search_results.all": "Uile",
|
||||
"search_results.hashtags": "Haischlibeanna",
|
||||
"search_results.all": "Gach",
|
||||
"search_results.hashtags": "Haischlib",
|
||||
"search_results.nothing_found": "Níorbh fhéidir aon rud a aimsiú do na téarmaí cuardaigh seo",
|
||||
"search_results.see_all": "Gach rud a fheicáil",
|
||||
"search_results.statuses": "Postálacha",
|
||||
@ -705,12 +705,12 @@
|
||||
"sign_in_banner.sso_redirect": "Logáil isteach nó Cláraigh",
|
||||
"status.admin_account": "Oscail comhéadan modhnóireachta do @{name}",
|
||||
"status.admin_domain": "Oscail comhéadan modhnóireachta le haghaidh {domain}",
|
||||
"status.admin_status": "Open this status in the moderation interface",
|
||||
"status.admin_status": "Oscail an postáil seo sa chomhéadan modhnóireachta",
|
||||
"status.block": "Bac @{name}",
|
||||
"status.bookmark": "Leabharmharcanna",
|
||||
"status.cancel_reblog_private": "Dímhol",
|
||||
"status.cannot_reblog": "Ní féidir an phostáil seo a mholadh",
|
||||
"status.copy": "Copy link to status",
|
||||
"status.copy": "Cóipeáil an nasc chuig an bpostáil",
|
||||
"status.delete": "Scrios",
|
||||
"status.detailed_status": "Amharc comhrá mionsonraithe",
|
||||
"status.direct": "Luaigh @{name} go príobháideach",
|
||||
@ -734,11 +734,11 @@
|
||||
"status.more": "Tuilleadh",
|
||||
"status.mute": "Balbhaigh @{name}",
|
||||
"status.mute_conversation": "Balbhaigh comhrá",
|
||||
"status.open": "Expand this status",
|
||||
"status.open": "Leathnaigh an post seo",
|
||||
"status.pin": "Pionnáil ar do phróifíl",
|
||||
"status.pinned": "Postáil pionnáilte",
|
||||
"status.read_more": "Léan a thuilleadh",
|
||||
"status.reblog": "Mol",
|
||||
"status.reblog": "Treisiú",
|
||||
"status.reblog_private": "Mol le léargas bunúsach",
|
||||
"status.reblogged_by": "Mhol {name}",
|
||||
"status.reblogs": "{count, plural, one {buaic} other {buaic}}",
|
||||
@ -757,7 +757,7 @@
|
||||
"status.show_more": "Taispeáin níos mó",
|
||||
"status.show_more_all": "Taispeáin níos mó d'uile",
|
||||
"status.show_original": "Taispeáin bunchóip",
|
||||
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}",
|
||||
"status.title.with_attachments": "{user} a sheol {attachmentCount, plural, one {ceangal} two {{attachmentCount} ceangal} few {{attachmentCount} ceangail} many {{attachmentCount} ceangal} other {{attachmentCount} ceangal}}",
|
||||
"status.translate": "Aistrigh",
|
||||
"status.translated_from_with": "D'Aistrigh ón {lang} ag úsáid {provider}",
|
||||
"status.uncached_media_warning": "Níl an réamhamharc ar fáil",
|
||||
|
@ -28,7 +28,7 @@
|
||||
"account.featured_tags.last_status_at": "Tasuffeɣt taneggarut ass n {date}",
|
||||
"account.featured_tags.last_status_never": "Ulac tisuffaɣ",
|
||||
"account.follow": "Ḍfer",
|
||||
"account.follow_back": "Ḍfer-it ula d kečč·m",
|
||||
"account.follow_back": "Ḍfer-it ula d kečč·mm",
|
||||
"account.followers": "Imeḍfaren",
|
||||
"account.followers.empty": "Ar tura, ulac yiwen i yeṭṭafaṛen amseqdac-agi.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} n umḍfar} other {{counter} n yimeḍfaren}}",
|
||||
@ -38,6 +38,7 @@
|
||||
"account.go_to_profile": "Ddu ɣer umaɣnu",
|
||||
"account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
|
||||
"account.joined_short": "Izeddi da seg ass n",
|
||||
"account.languages": "Beddel tutlayin yettwajerden",
|
||||
"account.link_verified_on": "Taɣara n useɣwen-a tettwasenqed ass n {date}",
|
||||
"account.locked_info": "Amiḍan-agi uslig isekweṛ. D bab-is kan i izemren ad yeǧǧ, s ufus-is, win ara t-iḍefṛen.",
|
||||
"account.media": "Timidyatin",
|
||||
@ -235,6 +236,7 @@
|
||||
"follow_request.authorize": "Ssireg",
|
||||
"follow_request.reject": "Agi",
|
||||
"follow_suggestions.dismiss": "Dayen ur t-id-skan ara",
|
||||
"follow_suggestions.popular_suggestion_longer": "Yettwassen deg {domain}",
|
||||
"follow_suggestions.view_all": "Wali-ten akk",
|
||||
"follow_suggestions.who_to_follow": "Ad tḍefreḍ?",
|
||||
"followed_tags": "Ihacṭagen yettwaḍfaren",
|
||||
|
207
app/javascript/mastodon/models/notification_group.ts
Normal file
207
app/javascript/mastodon/models/notification_group.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import type {
|
||||
ApiAccountRelationshipSeveranceEventJSON,
|
||||
ApiAccountWarningJSON,
|
||||
BaseNotificationGroupJSON,
|
||||
ApiNotificationGroupJSON,
|
||||
ApiNotificationJSON,
|
||||
NotificationType,
|
||||
NotificationWithStatusType,
|
||||
} from 'mastodon/api_types/notifications';
|
||||
import type { ApiReportJSON } from 'mastodon/api_types/reports';
|
||||
|
||||
// Maximum number of avatars displayed in a notification group
|
||||
// This corresponds to the max lenght of `group.sampleAccountIds`
|
||||
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
|
||||
|
||||
interface BaseNotificationGroup
|
||||
extends Omit<BaseNotificationGroupJSON, 'sample_accounts'> {
|
||||
sampleAccountIds: string[];
|
||||
}
|
||||
|
||||
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
|
||||
extends BaseNotificationGroup {
|
||||
type: Type;
|
||||
statusId: string;
|
||||
}
|
||||
|
||||
interface BaseNotification<Type extends NotificationType>
|
||||
extends BaseNotificationGroup {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export type NotificationGroupFavourite =
|
||||
BaseNotificationWithStatus<'favourite'>;
|
||||
export type NotificationGroupReblog = BaseNotificationWithStatus<'reblog'>;
|
||||
export type NotificationGroupStatus = BaseNotificationWithStatus<'status'>;
|
||||
export type NotificationGroupMention = BaseNotificationWithStatus<'mention'>;
|
||||
export type NotificationGroupPoll = BaseNotificationWithStatus<'poll'>;
|
||||
export type NotificationGroupUpdate = BaseNotificationWithStatus<'update'>;
|
||||
export type NotificationGroupFollow = BaseNotification<'follow'>;
|
||||
export type NotificationGroupFollowRequest = BaseNotification<'follow_request'>;
|
||||
export type NotificationGroupAdminSignUp = BaseNotification<'admin.sign_up'>;
|
||||
|
||||
export type AccountWarningAction =
|
||||
| 'none'
|
||||
| 'disable'
|
||||
| 'mark_statuses_as_sensitive'
|
||||
| 'delete_statuses'
|
||||
| 'sensitive'
|
||||
| 'silence'
|
||||
| 'suspend';
|
||||
export interface AccountWarning
|
||||
extends Omit<ApiAccountWarningJSON, 'target_account'> {
|
||||
targetAccountId: string;
|
||||
}
|
||||
|
||||
export interface NotificationGroupModerationWarning
|
||||
extends BaseNotification<'moderation_warning'> {
|
||||
moderationWarning: AccountWarning;
|
||||
}
|
||||
|
||||
type AccountRelationshipSeveranceEvent =
|
||||
ApiAccountRelationshipSeveranceEventJSON;
|
||||
export interface NotificationGroupSeveredRelationships
|
||||
extends BaseNotification<'severed_relationships'> {
|
||||
event: AccountRelationshipSeveranceEvent;
|
||||
}
|
||||
|
||||
interface Report extends Omit<ApiReportJSON, 'target_account'> {
|
||||
targetAccountId: string;
|
||||
}
|
||||
|
||||
export interface NotificationGroupAdminReport
|
||||
extends BaseNotification<'admin.report'> {
|
||||
report: Report;
|
||||
}
|
||||
|
||||
export type NotificationGroup =
|
||||
| NotificationGroupFavourite
|
||||
| NotificationGroupReblog
|
||||
| NotificationGroupStatus
|
||||
| NotificationGroupMention
|
||||
| NotificationGroupPoll
|
||||
| NotificationGroupUpdate
|
||||
| NotificationGroupFollow
|
||||
| NotificationGroupFollowRequest
|
||||
| NotificationGroupModerationWarning
|
||||
| NotificationGroupSeveredRelationships
|
||||
| NotificationGroupAdminSignUp
|
||||
| NotificationGroupAdminReport;
|
||||
|
||||
function createReportFromJSON(reportJSON: ApiReportJSON): Report {
|
||||
const { target_account, ...report } = reportJSON;
|
||||
return {
|
||||
targetAccountId: target_account.id,
|
||||
...report,
|
||||
};
|
||||
}
|
||||
|
||||
function createAccountWarningFromJSON(
|
||||
warningJSON: ApiAccountWarningJSON,
|
||||
): AccountWarning {
|
||||
const { target_account, ...warning } = warningJSON;
|
||||
return {
|
||||
targetAccountId: target_account.id,
|
||||
...warning,
|
||||
};
|
||||
}
|
||||
|
||||
function createAccountRelationshipSeveranceEventFromJSON(
|
||||
eventJson: ApiAccountRelationshipSeveranceEventJSON,
|
||||
): AccountRelationshipSeveranceEvent {
|
||||
return eventJson;
|
||||
}
|
||||
|
||||
export function createNotificationGroupFromJSON(
|
||||
groupJson: ApiNotificationGroupJSON,
|
||||
): NotificationGroup {
|
||||
const { sample_accounts, ...group } = groupJson;
|
||||
const sampleAccountIds = sample_accounts.map((account) => account.id);
|
||||
|
||||
switch (group.type) {
|
||||
case 'favourite':
|
||||
case 'reblog':
|
||||
case 'status':
|
||||
case 'mention':
|
||||
case 'poll':
|
||||
case 'update': {
|
||||
const { status, ...groupWithoutStatus } = group;
|
||||
return {
|
||||
statusId: status.id,
|
||||
sampleAccountIds,
|
||||
...groupWithoutStatus,
|
||||
};
|
||||
}
|
||||
case 'admin.report': {
|
||||
const { report, ...groupWithoutTargetAccount } = group;
|
||||
return {
|
||||
report: createReportFromJSON(report),
|
||||
sampleAccountIds,
|
||||
...groupWithoutTargetAccount,
|
||||
};
|
||||
}
|
||||
case 'severed_relationships':
|
||||
return {
|
||||
...group,
|
||||
event: createAccountRelationshipSeveranceEventFromJSON(group.event),
|
||||
sampleAccountIds,
|
||||
};
|
||||
|
||||
case 'moderation_warning': {
|
||||
const { moderation_warning, ...groupWithoutModerationWarning } = group;
|
||||
return {
|
||||
...groupWithoutModerationWarning,
|
||||
moderationWarning: createAccountWarningFromJSON(moderation_warning),
|
||||
sampleAccountIds,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
sampleAccountIds,
|
||||
...group,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationGroupFromNotificationJSON(
|
||||
notification: ApiNotificationJSON,
|
||||
) {
|
||||
const group = {
|
||||
sampleAccountIds: [notification.account.id],
|
||||
group_key: notification.group_key,
|
||||
notifications_count: 1,
|
||||
type: notification.type,
|
||||
most_recent_notification_id: notification.id,
|
||||
page_min_id: notification.id,
|
||||
page_max_id: notification.id,
|
||||
latest_page_notification_at: notification.created_at,
|
||||
} as NotificationGroup;
|
||||
|
||||
switch (notification.type) {
|
||||
case 'favourite':
|
||||
case 'reblog':
|
||||
case 'status':
|
||||
case 'mention':
|
||||
case 'poll':
|
||||
case 'update':
|
||||
return { ...group, statusId: notification.status.id };
|
||||
case 'admin.report':
|
||||
return { ...group, report: createReportFromJSON(notification.report) };
|
||||
case 'severed_relationships':
|
||||
return {
|
||||
...group,
|
||||
event: createAccountRelationshipSeveranceEventFromJSON(
|
||||
notification.event,
|
||||
),
|
||||
};
|
||||
case 'moderation_warning':
|
||||
return {
|
||||
...group,
|
||||
moderationWarning: createAccountWarningFromJSON(
|
||||
notification.moderation_warning,
|
||||
),
|
||||
};
|
||||
default:
|
||||
return group;
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ import { markersReducer } from './markers';
|
||||
import media_attachments from './media_attachments';
|
||||
import meta from './meta';
|
||||
import { modalReducer } from './modal';
|
||||
import { notificationGroupsReducer } from './notification_groups';
|
||||
import { notificationPolicyReducer } from './notification_policy';
|
||||
import { notificationRequestsReducer } from './notification_requests';
|
||||
import notifications from './notifications';
|
||||
@ -65,6 +66,7 @@ const reducers = {
|
||||
search,
|
||||
media_attachments,
|
||||
notifications,
|
||||
notificationGroups: notificationGroupsReducer,
|
||||
height_cache,
|
||||
custom_emojis,
|
||||
lists,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { submitMarkersAction } from 'mastodon/actions/markers';
|
||||
import { submitMarkersAction, fetchMarkers } from 'mastodon/actions/markers';
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
|
||||
const initialState = {
|
||||
home: '0',
|
||||
@ -15,4 +16,23 @@ export const markersReducer = createReducer(initialState, (builder) => {
|
||||
if (notifications) state.notifications = notifications;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchMarkers.fulfilled,
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: {
|
||||
markers: { home, notifications },
|
||||
},
|
||||
},
|
||||
) => {
|
||||
if (home && compareId(home.last_read_id, state.home) > 0)
|
||||
state.home = home.last_read_id;
|
||||
if (
|
||||
notifications &&
|
||||
compareId(notifications.last_read_id, state.notifications) > 0
|
||||
)
|
||||
state.notifications = notifications.last_read_id;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
508
app/javascript/mastodon/reducers/notification_groups.ts
Normal file
508
app/javascript/mastodon/reducers/notification_groups.ts
Normal file
@ -0,0 +1,508 @@
|
||||
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
authorizeFollowRequestSuccess,
|
||||
blockAccountSuccess,
|
||||
muteAccountSuccess,
|
||||
rejectFollowRequestSuccess,
|
||||
} from 'mastodon/actions/accounts_typed';
|
||||
import { focusApp, unfocusApp } from 'mastodon/actions/app';
|
||||
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks_typed';
|
||||
import { fetchMarkers } from 'mastodon/actions/markers';
|
||||
import {
|
||||
clearNotifications,
|
||||
fetchNotifications,
|
||||
fetchNotificationsGap,
|
||||
processNewNotificationForGroups,
|
||||
loadPending,
|
||||
updateScrollPosition,
|
||||
markNotificationsAsRead,
|
||||
mountNotifications,
|
||||
unmountNotifications,
|
||||
} from 'mastodon/actions/notification_groups';
|
||||
import {
|
||||
disconnectTimeline,
|
||||
timelineDelete,
|
||||
} from 'mastodon/actions/timelines_typed';
|
||||
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import { usePendingItems } from 'mastodon/initial_state';
|
||||
import {
|
||||
NOTIFICATIONS_GROUP_MAX_AVATARS,
|
||||
createNotificationGroupFromJSON,
|
||||
createNotificationGroupFromNotificationJSON,
|
||||
} from 'mastodon/models/notification_group';
|
||||
import type { NotificationGroup } from 'mastodon/models/notification_group';
|
||||
|
||||
const NOTIFICATIONS_TRIM_LIMIT = 50;
|
||||
|
||||
export interface NotificationGap {
|
||||
type: 'gap';
|
||||
maxId?: string;
|
||||
sinceId?: string;
|
||||
}
|
||||
|
||||
interface NotificationGroupsState {
|
||||
groups: (NotificationGroup | NotificationGap)[];
|
||||
pendingGroups: (NotificationGroup | NotificationGap)[];
|
||||
scrolledToTop: boolean;
|
||||
isLoading: boolean;
|
||||
lastReadId: string;
|
||||
mounted: number;
|
||||
isTabVisible: boolean;
|
||||
}
|
||||
|
||||
const initialState: NotificationGroupsState = {
|
||||
groups: [],
|
||||
pendingGroups: [], // holds pending groups in slow mode
|
||||
scrolledToTop: false,
|
||||
isLoading: false,
|
||||
// The following properties are used to track unread notifications
|
||||
lastReadId: '0', // used for unread notifications
|
||||
mounted: 0, // number of mounted notification list components, usually 0 or 1
|
||||
isTabVisible: true,
|
||||
};
|
||||
|
||||
function filterNotificationsForAccounts(
|
||||
groups: NotificationGroupsState['groups'],
|
||||
accountIds: string[],
|
||||
onlyForType?: string,
|
||||
) {
|
||||
groups = groups
|
||||
.map((group) => {
|
||||
if (
|
||||
group.type !== 'gap' &&
|
||||
(!onlyForType || group.type === onlyForType)
|
||||
) {
|
||||
const previousLength = group.sampleAccountIds.length;
|
||||
|
||||
group.sampleAccountIds = group.sampleAccountIds.filter(
|
||||
(id) => !accountIds.includes(id),
|
||||
);
|
||||
|
||||
const newLength = group.sampleAccountIds.length;
|
||||
const removed = previousLength - newLength;
|
||||
|
||||
group.notifications_count -= removed;
|
||||
}
|
||||
|
||||
return group;
|
||||
})
|
||||
.filter(
|
||||
(group) => group.type === 'gap' || group.sampleAccountIds.length > 0,
|
||||
);
|
||||
mergeGaps(groups);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function filterNotificationsForStatus(
|
||||
groups: NotificationGroupsState['groups'],
|
||||
statusId: string,
|
||||
) {
|
||||
groups = groups.filter(
|
||||
(group) =>
|
||||
group.type === 'gap' ||
|
||||
!('statusId' in group) ||
|
||||
group.statusId !== statusId,
|
||||
);
|
||||
mergeGaps(groups);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function removeNotificationsForAccounts(
|
||||
state: NotificationGroupsState,
|
||||
accountIds: string[],
|
||||
onlyForType?: string,
|
||||
) {
|
||||
state.groups = filterNotificationsForAccounts(
|
||||
state.groups,
|
||||
accountIds,
|
||||
onlyForType,
|
||||
);
|
||||
state.pendingGroups = filterNotificationsForAccounts(
|
||||
state.pendingGroups,
|
||||
accountIds,
|
||||
onlyForType,
|
||||
);
|
||||
}
|
||||
|
||||
function removeNotificationsForStatus(
|
||||
state: NotificationGroupsState,
|
||||
statusId: string,
|
||||
) {
|
||||
state.groups = filterNotificationsForStatus(state.groups, statusId);
|
||||
state.pendingGroups = filterNotificationsForStatus(
|
||||
state.pendingGroups,
|
||||
statusId,
|
||||
);
|
||||
}
|
||||
|
||||
function isNotificationGroup(
|
||||
groupOrGap: NotificationGroup | NotificationGap,
|
||||
): groupOrGap is NotificationGroup {
|
||||
return groupOrGap.type !== 'gap';
|
||||
}
|
||||
|
||||
// Merge adjacent gaps in `groups` in-place
|
||||
function mergeGaps(groups: NotificationGroupsState['groups']) {
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const firstGroupOrGap = groups[i];
|
||||
|
||||
if (firstGroupOrGap?.type === 'gap') {
|
||||
let lastGap = firstGroupOrGap;
|
||||
let j = i + 1;
|
||||
|
||||
for (; j < groups.length; j++) {
|
||||
const groupOrGap = groups[j];
|
||||
if (groupOrGap?.type === 'gap') lastGap = groupOrGap;
|
||||
else break;
|
||||
}
|
||||
|
||||
if (j - i > 1) {
|
||||
groups.splice(i, j - i, {
|
||||
type: 'gap',
|
||||
maxId: firstGroupOrGap.maxId,
|
||||
sinceId: lastGap.sinceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if `groups[index-1]` and `groups[index]` are gaps, and merge them in-place if they are
|
||||
function mergeGapsAround(
|
||||
groups: NotificationGroupsState['groups'],
|
||||
index: number,
|
||||
) {
|
||||
if (index > 0) {
|
||||
const potentialFirstGap = groups[index - 1];
|
||||
const potentialSecondGap = groups[index];
|
||||
|
||||
if (
|
||||
potentialFirstGap?.type === 'gap' &&
|
||||
potentialSecondGap?.type === 'gap'
|
||||
) {
|
||||
groups.splice(index - 1, 2, {
|
||||
type: 'gap',
|
||||
maxId: potentialFirstGap.maxId,
|
||||
sinceId: potentialSecondGap.sinceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processNewNotification(
|
||||
groups: NotificationGroupsState['groups'],
|
||||
notification: ApiNotificationJSON,
|
||||
) {
|
||||
const existingGroupIndex = groups.findIndex(
|
||||
(group) =>
|
||||
group.type !== 'gap' && group.group_key === notification.group_key,
|
||||
);
|
||||
|
||||
// In any case, we are going to add a group at the top
|
||||
// If there is currently a gap at the top, now is the time to update it
|
||||
if (groups.length > 0 && groups[0]?.type === 'gap') {
|
||||
groups[0].maxId = notification.id;
|
||||
}
|
||||
|
||||
if (existingGroupIndex > -1) {
|
||||
const existingGroup = groups[existingGroupIndex];
|
||||
|
||||
if (
|
||||
existingGroup &&
|
||||
existingGroup.type !== 'gap' &&
|
||||
!existingGroup.sampleAccountIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post
|
||||
) {
|
||||
// Update the existing group
|
||||
if (
|
||||
existingGroup.sampleAccountIds.unshift(notification.account.id) >
|
||||
NOTIFICATIONS_GROUP_MAX_AVATARS
|
||||
)
|
||||
existingGroup.sampleAccountIds.pop();
|
||||
|
||||
existingGroup.most_recent_notification_id = notification.id;
|
||||
existingGroup.page_max_id = notification.id;
|
||||
existingGroup.latest_page_notification_at = notification.created_at;
|
||||
existingGroup.notifications_count += 1;
|
||||
|
||||
groups.splice(existingGroupIndex, 1);
|
||||
mergeGapsAround(groups, existingGroupIndex);
|
||||
|
||||
groups.unshift(existingGroup);
|
||||
}
|
||||
} else {
|
||||
// Create a new group
|
||||
groups.unshift(createNotificationGroupFromNotificationJSON(notification));
|
||||
}
|
||||
}
|
||||
|
||||
function trimNotifications(state: NotificationGroupsState) {
|
||||
if (state.scrolledToTop) {
|
||||
state.groups.splice(NOTIFICATIONS_TRIM_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldMarkNewNotificationsAsRead(
|
||||
{
|
||||
isTabVisible,
|
||||
scrolledToTop,
|
||||
mounted,
|
||||
lastReadId,
|
||||
groups,
|
||||
}: NotificationGroupsState,
|
||||
ignoreScroll = false,
|
||||
) {
|
||||
const isMounted = mounted > 0;
|
||||
const oldestGroup = groups.findLast(isNotificationGroup);
|
||||
const hasMore = groups.at(-1)?.type === 'gap';
|
||||
const oldestGroupReached =
|
||||
!hasMore ||
|
||||
lastReadId === '0' ||
|
||||
(oldestGroup?.page_min_id &&
|
||||
compareId(oldestGroup.page_min_id, lastReadId) <= 0);
|
||||
|
||||
return (
|
||||
isTabVisible &&
|
||||
(ignoreScroll || scrolledToTop) &&
|
||||
isMounted &&
|
||||
oldestGroupReached
|
||||
);
|
||||
}
|
||||
|
||||
function updateLastReadId(
|
||||
state: NotificationGroupsState,
|
||||
group: NotificationGroup | undefined = undefined,
|
||||
) {
|
||||
if (shouldMarkNewNotificationsAsRead(state)) {
|
||||
group = group ?? state.groups.find(isNotificationGroup);
|
||||
if (
|
||||
group?.page_max_id &&
|
||||
compareId(state.lastReadId, group.page_max_id) < 0
|
||||
)
|
||||
state.lastReadId = group.page_max_id;
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
initialState,
|
||||
(builder) => {
|
||||
builder
|
||||
.addCase(fetchNotifications.fulfilled, (state, action) => {
|
||||
state.groups = action.payload.map((json) =>
|
||||
json.type === 'gap' ? json : createNotificationGroupFromJSON(json),
|
||||
);
|
||||
state.isLoading = false;
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
||||
const { notifications } = action.payload;
|
||||
|
||||
// find the gap in the existing notifications
|
||||
const gapIndex = state.groups.findIndex(
|
||||
(groupOrGap) =>
|
||||
groupOrGap.type === 'gap' &&
|
||||
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
|
||||
groupOrGap.maxId === action.meta.arg.gap.maxId,
|
||||
);
|
||||
|
||||
if (gapIndex < 0)
|
||||
// We do not know where to insert, let's return
|
||||
return;
|
||||
|
||||
// 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 = 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;
|
||||
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
|
||||
const notification = action.payload;
|
||||
processNewNotification(
|
||||
usePendingItems ? state.pendingGroups : state.groups,
|
||||
notification,
|
||||
);
|
||||
updateLastReadId(state);
|
||||
trimNotifications(state);
|
||||
})
|
||||
.addCase(disconnectTimeline, (state, action) => {
|
||||
if (action.payload.timeline === 'home') {
|
||||
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
|
||||
state.groups.unshift({
|
||||
type: 'gap',
|
||||
sinceId: state.groups[0]?.page_min_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(timelineDelete, (state, action) => {
|
||||
removeNotificationsForStatus(state, action.payload.statusId);
|
||||
})
|
||||
.addCase(clearNotifications.pending, (state) => {
|
||||
state.groups = [];
|
||||
state.pendingGroups = [];
|
||||
})
|
||||
.addCase(blockAccountSuccess, (state, action) => {
|
||||
removeNotificationsForAccounts(state, [action.payload.relationship.id]);
|
||||
})
|
||||
.addCase(muteAccountSuccess, (state, action) => {
|
||||
if (action.payload.relationship.muting_notifications)
|
||||
removeNotificationsForAccounts(state, [
|
||||
action.payload.relationship.id,
|
||||
]);
|
||||
})
|
||||
.addCase(blockDomainSuccess, (state, action) => {
|
||||
removeNotificationsForAccounts(
|
||||
state,
|
||||
action.payload.accounts.map((account) => account.id),
|
||||
);
|
||||
})
|
||||
.addCase(loadPending, (state) => {
|
||||
// First, remove any existing group and merge data
|
||||
state.pendingGroups.forEach((group) => {
|
||||
if (group.type !== 'gap') {
|
||||
const existingGroupIndex = state.groups.findIndex(
|
||||
(groupOrGap) =>
|
||||
isNotificationGroup(groupOrGap) &&
|
||||
groupOrGap.group_key === group.group_key,
|
||||
);
|
||||
if (existingGroupIndex > -1) {
|
||||
const existingGroup = state.groups[existingGroupIndex];
|
||||
if (existingGroup && existingGroup.type !== 'gap') {
|
||||
group.notifications_count += existingGroup.notifications_count;
|
||||
group.sampleAccountIds = group.sampleAccountIds
|
||||
.concat(existingGroup.sampleAccountIds)
|
||||
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
|
||||
state.groups.splice(existingGroupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
trimNotifications(state);
|
||||
});
|
||||
|
||||
// Then build the consolidated list and clear pending groups
|
||||
state.groups = state.pendingGroups.concat(state.groups);
|
||||
state.pendingGroups = [];
|
||||
})
|
||||
.addCase(updateScrollPosition, (state, action) => {
|
||||
state.scrolledToTop = action.payload.top;
|
||||
updateLastReadId(state);
|
||||
trimNotifications(state);
|
||||
})
|
||||
.addCase(markNotificationsAsRead, (state) => {
|
||||
const mostRecentGroup = state.groups.find(isNotificationGroup);
|
||||
if (
|
||||
mostRecentGroup?.page_max_id &&
|
||||
compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0
|
||||
)
|
||||
state.lastReadId = mostRecentGroup.page_max_id;
|
||||
})
|
||||
.addCase(fetchMarkers.fulfilled, (state, action) => {
|
||||
if (
|
||||
action.payload.markers.notifications &&
|
||||
compareId(
|
||||
state.lastReadId,
|
||||
action.payload.markers.notifications.last_read_id,
|
||||
) < 0
|
||||
)
|
||||
state.lastReadId = action.payload.markers.notifications.last_read_id;
|
||||
})
|
||||
.addCase(mountNotifications, (state) => {
|
||||
state.mounted += 1;
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(unmountNotifications, (state) => {
|
||||
state.mounted -= 1;
|
||||
})
|
||||
.addCase(focusApp, (state) => {
|
||||
state.isTabVisible = true;
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(unfocusApp, (state) => {
|
||||
state.isTabVisible = false;
|
||||
})
|
||||
.addMatcher(
|
||||
isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess),
|
||||
(state, action) => {
|
||||
removeNotificationsForAccounts(
|
||||
state,
|
||||
[action.payload.id],
|
||||
'follow_request',
|
||||
);
|
||||
},
|
||||
)
|
||||
.addMatcher(
|
||||
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
|
||||
(state) => {
|
||||
state.isLoading = true;
|
||||
},
|
||||
)
|
||||
.addMatcher(
|
||||
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected),
|
||||
(state) => {
|
||||
state.isLoading = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
@ -16,13 +16,13 @@ import {
|
||||
import {
|
||||
fetchMarkers,
|
||||
} from '../actions/markers';
|
||||
import { clearNotifications } from '../actions/notification_groups';
|
||||
import {
|
||||
notificationsUpdate,
|
||||
NOTIFICATIONS_EXPAND_SUCCESS,
|
||||
NOTIFICATIONS_EXPAND_REQUEST,
|
||||
NOTIFICATIONS_EXPAND_FAIL,
|
||||
NOTIFICATIONS_FILTER_SET,
|
||||
NOTIFICATIONS_CLEAR,
|
||||
NOTIFICATIONS_SCROLL_TOP,
|
||||
NOTIFICATIONS_LOAD_PENDING,
|
||||
NOTIFICATIONS_MOUNT,
|
||||
@ -290,7 +290,7 @@ export default function notifications(state = initialState, action) {
|
||||
case authorizeFollowRequestSuccess.type:
|
||||
case rejectFollowRequestSuccess.type:
|
||||
return filterNotifications(state, [action.payload.id], 'follow_request');
|
||||
case NOTIFICATIONS_CLEAR:
|
||||
case clearNotifications.pending.type:
|
||||
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
|
||||
case timelineDelete.type:
|
||||
return deleteByStatus(state, action.payload.statusId);
|
||||
|
34
app/javascript/mastodon/selectors/notifications.ts
Normal file
34
app/javascript/mastodon/selectors/notifications.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
export const selectUnreadNotificationGroupsCount = createSelector(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.lastReadId,
|
||||
(s: RootState) => s.notificationGroups.pendingGroups,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
],
|
||||
(notificationMarker, pendingGroups, groups) => {
|
||||
return (
|
||||
groups.filter(
|
||||
(group) =>
|
||||
group.type !== 'gap' &&
|
||||
group.page_max_id &&
|
||||
compareId(group.page_max_id, notificationMarker) > 0,
|
||||
).length +
|
||||
pendingGroups.filter(
|
||||
(group) =>
|
||||
group.type !== 'gap' &&
|
||||
group.page_max_id &&
|
||||
compareId(group.page_max_id, notificationMarker) > 0,
|
||||
).length
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const selectPendingNotificationGroupsCount = createSelector(
|
||||
[(s: RootState) => s.notificationGroups.pendingGroups],
|
||||
(pendingGroups) =>
|
||||
pendingGroups.filter((group) => group.type !== 'gap').length,
|
||||
);
|
40
app/javascript/mastodon/selectors/settings.ts
Normal file
40
app/javascript/mastodon/selectors/settings.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
|
||||
export const selectSettingsNotificationsShows = (state: RootState) =>
|
||||
state.settings.getIn(['notifications', 'shows']).toJS() as Record<
|
||||
string,
|
||||
boolean
|
||||
>;
|
||||
|
||||
export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
|
||||
Object.entries(selectSettingsNotificationsShows(state))
|
||||
.filter(([_type, enabled]) => !enabled)
|
||||
.map(([type, _enabled]) => type);
|
||||
|
||||
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
|
||||
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
|
||||
|
||||
export const selectSettingsNotificationsQuickFilterActive = (
|
||||
state: RootState,
|
||||
) => state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
|
||||
|
||||
export const selectSettingsNotificationsQuickFilterAdvanced = (
|
||||
state: RootState,
|
||||
) =>
|
||||
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
|
||||
|
||||
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
|
||||
state.settings.getIn(['notifications', 'showUnread']) as boolean;
|
||||
|
||||
export const selectNeedsNotificationPermission = (state: RootState) =>
|
||||
(state.settings.getIn(['notifications', 'alerts']).includes(true) &&
|
||||
state.notifications.get('browserSupport') &&
|
||||
state.notifications.get('browserPermission') === 'default' &&
|
||||
!state.settings.getIn([
|
||||
'notifications',
|
||||
'dismissPermissionBanner',
|
||||
])) as boolean;
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
@ -1611,14 +1611,19 @@ body > [data-popper-placement] {
|
||||
}
|
||||
}
|
||||
|
||||
.status__wrapper-direct {
|
||||
.status__wrapper-direct,
|
||||
.notification-ungrouped--direct {
|
||||
background: rgba($ui-highlight-color, 0.05);
|
||||
|
||||
&:focus {
|
||||
background: rgba($ui-highlight-color, 0.05);
|
||||
background: rgba($ui-highlight-color, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.status__prepend {
|
||||
.status__wrapper-direct,
|
||||
.notification-ungrouped--direct {
|
||||
.status__prepend,
|
||||
.notification-ungrouped__header {
|
||||
color: $highlight-text-color;
|
||||
}
|
||||
}
|
||||
@ -2209,41 +2214,28 @@ a.account__display-name {
|
||||
}
|
||||
}
|
||||
|
||||
.notification__relationships-severance-event,
|
||||
.notification__moderation-warning {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.notification-group--link {
|
||||
color: $secondary-text-color;
|
||||
text-decoration: none;
|
||||
align-items: flex-start;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&:hover {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 2px;
|
||||
color: $highlight-text-color;
|
||||
}
|
||||
|
||||
&__content {
|
||||
.notification-group__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
|
||||
strong {
|
||||
strong,
|
||||
bdi {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10193,8 +10185,8 @@ noscript {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
padding: 24px 32px;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
gap: 8px;
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
@ -10204,10 +10196,8 @@ noscript {
|
||||
color: $secondary-text-color;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
.notification-group__icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__text {
|
||||
@ -10345,6 +10335,251 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
.notification-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&__icon {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
color: $dark-text-color;
|
||||
|
||||
.icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
&--follow &__icon,
|
||||
&--follow-request &__icon {
|
||||
color: $highlight-text-color;
|
||||
}
|
||||
|
||||
&--favourite &__icon {
|
||||
color: $gold-star;
|
||||
}
|
||||
|
||||
&--reblog &__icon {
|
||||
color: $valid-value-color;
|
||||
}
|
||||
|
||||
&--relationships-severance-event &__icon,
|
||||
&--admin-report &__icon,
|
||||
&--admin-sign-up &__icon {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
&--moderation-warning &__icon {
|
||||
color: $red-bookmark;
|
||||
}
|
||||
|
||||
&--follow-request &__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 50%;
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: $darker-text-color;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
bdi {
|
||||
font-weight: 700;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
time {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
overflow-y: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&__embedded-status {
|
||||
&__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
color: $dark-text-color;
|
||||
|
||||
bdi {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.account__avatar {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: -webkit-box;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: $dark-text-color;
|
||||
cursor: pointer;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
max-height: 4 * 22px;
|
||||
overflow: hidden;
|
||||
|
||||
p,
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-ungrouped {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $dark-text-color;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
padding-inline-start: 24px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
&__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.status__wrapper-direct {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
$icon-margin: 48px; // 40px avatar + 8px gap
|
||||
|
||||
.status__content,
|
||||
.status__action-bar,
|
||||
.media-gallery,
|
||||
.video-player,
|
||||
.audio-player,
|
||||
.attachment-list,
|
||||
.picture-in-picture-placeholder,
|
||||
.more-from-author,
|
||||
.status-card,
|
||||
.hashtag-bar {
|
||||
margin-inline-start: $icon-margin;
|
||||
width: calc(100% - $icon-margin);
|
||||
}
|
||||
|
||||
.more-from-author {
|
||||
width: calc(100% - $icon-margin + 2px);
|
||||
}
|
||||
|
||||
.status__content__read-more-button {
|
||||
margin-inline-start: $icon-margin;
|
||||
}
|
||||
|
||||
.notification__report {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-group--unread,
|
||||
.notification-ungrouped--unread {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-inline-start: 4px solid $highlight-text-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-card-controller[data-popper-reference-hidden='true'] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
@ -30,6 +30,7 @@ class Notification < ApplicationRecord
|
||||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
# Please update app/javascript/api_types/notification.ts if you change this
|
||||
PROPERTIES = {
|
||||
mention: {
|
||||
filterable: true,
|
||||
|
@ -3,13 +3,17 @@
|
||||
class NotificationGroup < ActiveModelSerializers::Model
|
||||
attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id
|
||||
|
||||
# Try to keep this consistent with `app/javascript/mastodon/models/notification_group.ts`
|
||||
SAMPLE_ACCOUNTS_SIZE = 8
|
||||
|
||||
def self.from_notification(notification, max_id: nil)
|
||||
if notification.group_key.present?
|
||||
# TODO: caching and preloading
|
||||
# TODO: caching, and, if caching, preloading
|
||||
scope = notification.account.notifications.where(group_key: notification.group_key)
|
||||
scope = scope.where(id: ..max_id) if max_id.present?
|
||||
|
||||
most_recent_notifications = scope.order(id: :desc).take(3)
|
||||
# Ideally, we would not load accounts for each notification group
|
||||
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)
|
||||
notifications_count = scope.count
|
||||
|
@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::NotificationGroupSerializer < ActiveModel::Serializer
|
||||
# Please update app/javascript/api_types/notification.ts when making changes to the attributes
|
||||
attributes :group_key, :notifications_count, :type, :most_recent_notification_id
|
||||
|
||||
attribute :page_min_id, if: :paginated?
|
||||
|
@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::NotificationSerializer < ActiveModel::Serializer
|
||||
# Please update app/javascript/api_types/notification.ts when making changes to the attributes
|
||||
attributes :id, :type, :created_at, :group_key
|
||||
|
||||
attribute :filtered, if: :filtered?
|
||||
|
@ -4,7 +4,6 @@ class NotifyService < BaseService
|
||||
include Redisable
|
||||
|
||||
MAXIMUM_GROUP_SPAN_HOURS = 12
|
||||
MAXIMUM_GROUP_GAP_TIME = 4.hours.to_i
|
||||
|
||||
NON_EMAIL_TYPES = %i(
|
||||
admin.report
|
||||
@ -217,9 +216,8 @@ class NotifyService < BaseService
|
||||
previous_bucket = redis.get(redis_key).to_i
|
||||
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
|
||||
|
||||
# Do not track groups past a given inactivity time
|
||||
# We do not concern ourselves with race conditions since we use hour buckets
|
||||
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_GAP_TIME)
|
||||
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
|
||||
|
||||
"#{type_prefix}-#{hour_bucket}"
|
||||
end
|
||||
|
@ -226,6 +226,7 @@ bg:
|
||||
update_custom_emoji: Обновяване на персонализираното емоджи
|
||||
update_domain_block: Обновяване на блокирането за домейна
|
||||
update_ip_block: Обновяване на правило за IP
|
||||
update_report: Обновяване на доклада
|
||||
update_status: Обновяване на публикация
|
||||
update_user_role: Обновяване на ролята
|
||||
actions:
|
||||
@ -638,6 +639,7 @@ bg:
|
||||
report: 'Докладване на #%{id}'
|
||||
reported_account: Докладван акаунт
|
||||
reported_by: Докладвано от
|
||||
reported_with_application: Докладвано с приложението
|
||||
resolved: Разрешено
|
||||
resolved_msg: Успешно разрешен доклад!
|
||||
skip_to_actions: Прескок към действия
|
||||
|
@ -116,3 +116,9 @@ ga:
|
||||
expired: imithe in éag, iarr ceann nua le do thoil
|
||||
not_found: níor aimsíodh é
|
||||
not_locked: nach raibh faoi ghlas
|
||||
not_saved:
|
||||
few: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
|
||||
many: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
|
||||
one: 'Chuir earráid 1 cosc ar an %{resource} seo a shábháil:'
|
||||
other: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
|
||||
two: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
|
||||
|
@ -5,7 +5,17 @@ ga:
|
||||
doorkeeper/application:
|
||||
name: Ainm feidhmchláir
|
||||
redirect_uri: Atreoraigh URI
|
||||
scopes: Scóip
|
||||
website: Suíomh gréasáin feidhmchláir
|
||||
errors:
|
||||
models:
|
||||
doorkeeper/application:
|
||||
attributes:
|
||||
redirect_uri:
|
||||
fragment_present: ní féidir blúire a bheith ann.
|
||||
invalid_uri: caithfidh gur URI bailí é.
|
||||
relative_uri: a bheith ina URI iomlán.
|
||||
secured_uri: caithfidh gur URI HTTPS/SSL é.
|
||||
doorkeeper:
|
||||
applications:
|
||||
buttons:
|
||||
@ -16,38 +26,172 @@ ga:
|
||||
submit: Cuir isteach
|
||||
confirmations:
|
||||
destroy: An bhfuil tú cinnte?
|
||||
edit:
|
||||
title: Cuir feidhmchlár in eagar
|
||||
form:
|
||||
error: Úps! Seiceáil d'fhoirm le haghaidh earráidí féideartha
|
||||
help:
|
||||
native_redirect_uri: Úsáid %{native_redirect_uri} le haghaidh tástálacha logánta
|
||||
redirect_uri: Úsáid líne amháin in aghaidh an URI
|
||||
scopes: Scóipeanna ar leith le spásanna. Fág bán chun na scóip réamhshocraithe a úsáid.
|
||||
index:
|
||||
application: Ainm feidhmchláir
|
||||
callback_url: URL aisghlaoch
|
||||
delete: Scrios
|
||||
empty: Níl aon iarratais agat.
|
||||
name: Ainm
|
||||
new: Feidhmchlár nua
|
||||
scopes: Scóip
|
||||
show: Taispeáin
|
||||
title: D'iarratais
|
||||
new:
|
||||
title: Feidhmchlár nua
|
||||
show:
|
||||
actions: Gníomhartha
|
||||
application_id: Eochair chliaint
|
||||
callback_urls: URLanna aisghlaoch
|
||||
scopes: Scóip
|
||||
secret: Rún cliaint
|
||||
title: 'Ainm feidhmchláir: %{name}'
|
||||
authorizations:
|
||||
buttons:
|
||||
authorize: Ceadaigh
|
||||
deny: Diúltaigh
|
||||
error:
|
||||
title: Tharla earráid
|
||||
new:
|
||||
prompt_html: Ba mhaith le %{client_name} cead rochtain a fháil ar do chuntas. Is iarratas tríú páirtí é. <strong>Mura bhfuil muinín agat as, níor cheart duit é a údarú.</strong>
|
||||
review_permissions: Ceadanna a athbhreithniú
|
||||
title: Tá údarú ag teastáil
|
||||
show:
|
||||
title: Cóipeáil an cód údaraithe seo agus greamaigh don fheidhmchlár é.
|
||||
authorized_applications:
|
||||
buttons:
|
||||
revoke: Cúlghair
|
||||
confirmations:
|
||||
revoke: An bhfuil tú cinnte?
|
||||
index:
|
||||
authorized_at: Ceadaithe ar %{date}
|
||||
description_html: Is feidhmchláir iad seo ar féidir rochtain a fháil ar do chuntas leis an API. Má tá feidhmchláir ann nach n-aithníonn tú anseo, nó má tá feidhmchlár mí-iompair, is féidir leat a rochtain a chúlghairm.
|
||||
last_used_at: Úsáidte an uair dheireanach ar %{date}
|
||||
never_used: Ná húsáideadh
|
||||
scopes: Ceadanna
|
||||
superapp: Inmheánach
|
||||
title: D’iarratais údaraithe
|
||||
errors:
|
||||
messages:
|
||||
access_denied: Shéan úinéir na hacmhainne nó an freastalaí údaraithe an t-iarratas.
|
||||
credential_flow_not_configured: Theip ar shreabhadh Dintiúir Pasfhocal Úinéir Acmhainne toisc go raibh Doorkeeper.configure.resource_owner_from_credentials díchumraithe.
|
||||
invalid_client: Theip ar fhíordheimhniú cliant de bharr cliant anaithnid, níl fíordheimhniú cliant san áireamh, nó modh fíordheimhnithe nach dtacaítear leis.
|
||||
invalid_grant: Tá an deonú údaraithe ar choinníoll neamhbhailí, imithe in éag, cúlghairthe, nach ionann é agus an URI atreoraithe a úsáideadh san iarratas ar údarú, nó gur eisíodh é chuig cliant eile.
|
||||
invalid_redirect_uri: Níl an uri atreoraithe atá san áireamh bailí.
|
||||
invalid_request:
|
||||
missing_param: 'Paraiméadar riachtanach in easnamh: %{value}.'
|
||||
request_not_authorized: Ní mór an t-iarratas a údarú. Tá an paraiméadar riachtanach chun iarratas a údarú in easnamh nó neamhbhailí.
|
||||
unknown: Tá paraiméadar riachtanach in easnamh ar an iarratas, folaíonn sé luach paraiméadar nach dtacaítear leis, nó tá sé míchumtha ar shlí eile.
|
||||
invalid_resource_owner: Níl na dintiúir úinéara acmhainne a soláthraíodh bailí, nó ní féidir úinéir na hacmhainne a aimsiú
|
||||
invalid_scope: Tá an scóip iarrtha neamhbhailí, anaithnid nó míchumtha.
|
||||
invalid_token:
|
||||
expired: Chuaigh an comhartha rochtana in éag
|
||||
revoked: Cúlghairmeadh an comhartha rochtana
|
||||
unknown: Tá an comhartha rochtana neamhbhailí
|
||||
resource_owner_authenticator_not_configured: Theip ar aimsiú Úinéir Acmhainne toisc go bhfuil Doorkeeper.configure.resource_owner_authenticator díchumraithe.
|
||||
server_error: Tháinig an freastalaí údaraithe ar riocht gan choinne a chuir cosc air an t-iarratas a chomhlíonadh.
|
||||
temporarily_unavailable: Ní féidir leis an bhfreastalaí údaraithe an t-iarratas a láimhseáil mar gheall ar ró-ualú sealadach nó cothabháil an fhreastalaí.
|
||||
unauthorized_client: Níl an cliant údaraithe an t-iarratas seo a dhéanamh leis an modh seo.
|
||||
unsupported_grant_type: Ní thacaíonn an freastalaí údaraithe leis an gcineál deontais údaraithe.
|
||||
unsupported_response_type: Ní thacaíonn an freastalaí údaraithe leis an gcineál freagartha seo.
|
||||
flash:
|
||||
applications:
|
||||
create:
|
||||
notice: Cruthaíodh feidhmchlár.
|
||||
destroy:
|
||||
notice: Scriosadh an feidhmchlár.
|
||||
update:
|
||||
notice: Feidhmchlár nuashonraithe.
|
||||
authorized_applications:
|
||||
destroy:
|
||||
notice: Cúlghairmeadh an t-iarratas.
|
||||
grouped_scopes:
|
||||
access:
|
||||
read: Rochtain inléite-amháin
|
||||
read/write: Léigh agus scríobh rochtain
|
||||
write: Rochtain scríofa-amháin
|
||||
title:
|
||||
accounts: Cuntais
|
||||
admin/accounts: Cuntas a riar
|
||||
admin/all: Feidhmeanna riaracháin go léir
|
||||
admin/reports: Tuarascálacha a riar
|
||||
all: Rochtain iomlán ar do chuntas Mastodon
|
||||
blocks: Bloic
|
||||
bookmarks: Leabharmharcanna
|
||||
conversations: Comhráite
|
||||
crypto: Criptiú ceann-go-deireadh
|
||||
favourites: Ceanáin
|
||||
filters: Scagairí
|
||||
follow: Leanann, Múchann agus Blocálann
|
||||
follows: Cuntais leanta
|
||||
lists: Liostaí
|
||||
media: Ceangaltáin meán
|
||||
mutes: Múchann
|
||||
notifications: Fógraí
|
||||
profile: Do phróifíl Mastodon
|
||||
push: Fógraí a bhrú
|
||||
reports: Tuarascálacha
|
||||
search: Cuardaigh
|
||||
statuses: Postálacha
|
||||
layouts:
|
||||
admin:
|
||||
nav:
|
||||
applications: Feidhmchláir
|
||||
oauth2_provider: Soláthraí OAuth2
|
||||
application:
|
||||
title: Tá údarú OAuth riachtanach
|
||||
scopes:
|
||||
admin:read: léigh na sonraí go léir ar an bhfreastalaí
|
||||
admin:read:accounts: faisnéis íogair na gcuntas go léir a léamh
|
||||
admin:read:canonical_email_blocks: léigh faisnéis íogair ar gach bloc ríomhphoist canónach
|
||||
admin:read:domain_allows: léigh faisnéis íogair gach fearainn
|
||||
admin:read:domain_blocks: léigh faisnéis íogair gach bloc fearainn
|
||||
admin:read:email_domain_blocks: léigh faisnéis íogair gach bloc fearainn ríomhphoist
|
||||
admin:read:ip_blocks: léigh faisnéis íogair gach bloic IP
|
||||
admin:read:reports: faisnéis íogair na dtuarascálacha agus na gcuntas tuairiscithe go léir a léamh
|
||||
admin:write: na sonraí go léir ar an bhfreastalaí a mhodhnú
|
||||
admin:write:accounts: gníomhartha modhnóireachta a dhéanamh ar chuntais
|
||||
admin:write:canonical_email_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna ríomhphoist chanónacha
|
||||
admin:write:domain_allows: gníomhartha modhnóireachta a dhéanamh ar cheadaíonn fearainn
|
||||
admin:write:domain_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna fearainn
|
||||
admin:write:email_domain_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna fearainn ríomhphoist
|
||||
admin:write:ip_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna IP
|
||||
admin:write:reports: gníomhartha modhnóireachta a dhéanamh ar thuarascálacha
|
||||
crypto: úsáid criptiú ceann-go-ceann
|
||||
follow: caidrimh chuntais a mhodhnú
|
||||
profile: léigh faisnéis phróifíle do chuntais amháin
|
||||
push: faigh do bhrúfhógraí
|
||||
read: léigh sonraí do chuntais go léir
|
||||
read:accounts: féach eolas cuntais
|
||||
read:blocks: féach ar do bloic
|
||||
read:bookmarks: féach ar do leabharmharcanna
|
||||
read:favourites: féach ar do cheanáin
|
||||
read:filters: féach ar do chuid scagairí
|
||||
read:follows: féach do chuid seo a leanas
|
||||
read:lists: féach ar do liostaí
|
||||
read:mutes: féach ar do bhalbh
|
||||
read:notifications: féach ar do chuid fógraí
|
||||
read:reports: féach ar do thuarascálacha
|
||||
read:search: cuardach ar do shon
|
||||
read:statuses: féach ar gach post
|
||||
write: sonraí do chuntais go léir a mhodhnú
|
||||
write:accounts: do phróifíl a mhodhnú
|
||||
write:blocks: cuntais agus fearainn a bhlocáil
|
||||
write:bookmarks: poist leabharmharcála
|
||||
write:conversations: comhráite balbh agus scrios
|
||||
write:favourites: poist is fearr leat
|
||||
write:filters: cruthaigh scagairí
|
||||
write:follows: daoine a leanúint
|
||||
write:lists: cruthaigh liostaí
|
||||
write:media: uaslódáil comhaid meáin
|
||||
write:mutes: balbhaigh daoine agus comhráite
|
||||
write:notifications: soiléir do chuid fógraí
|
||||
write:reports: tuairisc a thabhairt do dhaoine eile
|
||||
write:statuses: foilsigh poist
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -226,6 +226,7 @@ gl:
|
||||
update_custom_emoji: Actualizar emoticona personalizada
|
||||
update_domain_block: Actualizar bloqueo do dominio
|
||||
update_ip_block: Actualizar regra IP
|
||||
update_report: Actualización da denuncia
|
||||
update_status: Actualizar publicación
|
||||
update_user_role: Actualizar Rol
|
||||
actions:
|
||||
@ -638,6 +639,7 @@ gl:
|
||||
report: 'Denuncia #%{id}'
|
||||
reported_account: Conta denunciada
|
||||
reported_by: Denunciado por
|
||||
reported_with_application: Denunciado coa aplicación
|
||||
resolved: Resolto
|
||||
resolved_msg: Resolveuse con éxito a denuncia!
|
||||
skip_to_actions: Ir a accións
|
||||
|
@ -32,6 +32,8 @@ hi:
|
||||
silence: सीमा
|
||||
silenced: सीमित
|
||||
title: खाते
|
||||
reports:
|
||||
reported_with_application: एप्लीकेशन से रिपोर्ट किया गया
|
||||
system_checks:
|
||||
upload_check_privacy_error:
|
||||
message_html: "<strong> आपके वेब सर्वर का कन्फिगरेशन सही नहीं है। उपयोगकर्ताओं की निजता खतरे में है। </strong>"
|
||||
|
@ -226,7 +226,7 @@ hu:
|
||||
update_custom_emoji: Egyéni emodzsi frissítése
|
||||
update_domain_block: Domain tiltás frissítése
|
||||
update_ip_block: IP-szabály frissítése
|
||||
update_report: Bejelentés Frissítése
|
||||
update_report: Bejelentés frissítése
|
||||
update_status: Bejegyzés frissítése
|
||||
update_user_role: Szerepkör frissítése
|
||||
actions:
|
||||
|
@ -211,6 +211,7 @@ bg:
|
||||
setting_default_privacy: Поверителност на публикуване
|
||||
setting_default_sensitive: Все да се бележи мултимедията като деликатна
|
||||
setting_delete_modal: Показване на прозорче за потвърждение преди изтриване на публикация
|
||||
setting_disable_hover_cards: Изключване на прегледа на профила, премествайки показалеца отгоре
|
||||
setting_disable_swiping: Деактивиране на бързо плъзгащи движения
|
||||
setting_display_media: Показване на мултимедия
|
||||
setting_display_media_default: Стандартно
|
||||
@ -242,11 +243,13 @@ bg:
|
||||
warn: Скриване зад предупреждение
|
||||
form_admin_settings:
|
||||
activity_api_enabled: Публикуване на агрегатна статистика относно потребителската дейност в API
|
||||
app_icon: Икона на приложение
|
||||
backups_retention_period: Период за съхранение на потребителския архив
|
||||
bootstrap_timeline_accounts: Винаги да се препоръчват следните акаунти на нови потребители
|
||||
closed_registrations_message: Съобщение при неналична регистрация
|
||||
content_cache_retention_period: Период на запазване на отдалечено съдържание
|
||||
custom_css: Персонализиран CSS
|
||||
favicon: Сайтоикона
|
||||
mascot: Плашило талисман по избор (остаряло)
|
||||
media_cache_retention_period: Период на запазване на мултимедийния кеш
|
||||
peers_api_enabled: Публикуване на списъка с открити сървъри в API
|
||||
|
@ -2,47 +2,239 @@
|
||||
ga:
|
||||
simple_form:
|
||||
hints:
|
||||
account:
|
||||
discoverable: Seans go mbeidh do phostálacha poiblí agus do phróifíl le feiceáil nó molta i réimsí éagsúla de Mastodon agus is féidir do phróifíl a mholadh d’úsáideoirí eile.
|
||||
display_name: D'ainm iomlán nó d'ainm spraoi.
|
||||
fields: Do leathanach baile, forainmneacha, aois, rud ar bith is mian leat.
|
||||
indexable: Seans go mbeidh do phostálacha poiblí le feiceáil sna torthaí cuardaigh ar Mastodon. Seans go mbeidh daoine a d’idirghníomhaigh le do phostálacha in ann iad a chuardach beag beann ar.
|
||||
note: 'Is féidir leat @trá a dhéanamh ar dhaoine eile nó #hashtags.'
|
||||
show_collections: Beidh daoine in ann brabhsáil trí do seo a leanas agus do leanúna. Feicfidh na daoine a leanann tú go leanann tú iad beag beann ar.
|
||||
unlocked: Beidh daoine in ann tú a leanúint gan cead a iarraidh. Díthiceáil an dteastaíonn uait athbhreithniú a dhéanamh ar iarratais leantacha agus roghnaigh cé acu an nglacfaidh nó an diúltóidh tú do leantóirí nua.
|
||||
account_alias:
|
||||
acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú uaidh
|
||||
account_migration:
|
||||
acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú chuige
|
||||
account_warning_preset:
|
||||
text: Is féidir leat comhréir na bpost a úsáid, mar URLanna, hashtags agus lua
|
||||
title: Roghnach. Níl sé le feiceáil ag an bhfaighteoir
|
||||
admin_account_action:
|
||||
include_statuses: Feicfidh an t-úsáideoir cé na poist ba chúis leis an ngníomh modhnóireachta nó leis an rabhadh
|
||||
send_email_notification: Gheobhaidh an t-úsáideoir míniú ar an méid a tharla lena chuntas
|
||||
text_html: Roghnach. Is féidir leat comhréir phoist a úsáid. Is féidir leat <a href="%{path}">réamhshocruithe rabhaidh a chur leis</a> chun am a shábháil
|
||||
type_html: Roghnaigh cad atá le déanamh le <strong>%{acct}</strong>
|
||||
types:
|
||||
disable: Cuir cosc ar an úsáideoir a chuntas a úsáid, ach ná scrios ná folaigh a bhfuil ann.
|
||||
none: Bain úsáid as seo chun rabhadh a sheoladh chuig an úsáideoir, gan aon ghníomh eile a spreagadh.
|
||||
sensitive: Iallach a chur ar cheangaltáin meán an úsáideora seo go léir a bheith íogair.
|
||||
silence: Cosc a chur ar an úsáideoir ó bheith in ann postáil le hinfheictheacht phoiblí, a gcuid postálacha agus fógraí a cheilt ar dhaoine nach leanann iad. Dúnann sé gach tuairisc i gcoinne an chuntais seo.
|
||||
suspend: Cosc ar aon idirghníomhaíocht ón gcuntas seo nó chuig an gcuntas seo agus scrios a bhfuil ann. Inchúlaithe laistigh de 30 lá. Dúnann sé gach tuairisc i gcoinne an chuntais seo.
|
||||
warning_preset_id: Roghnach. Is féidir leat téacs saincheaptha a chur le deireadh an réamhshocraithe fós
|
||||
announcement:
|
||||
all_day: Nuair a dhéantar iad a sheiceáil, ní thaispeánfar ach dátaí an raon ama
|
||||
ends_at: Roghnach. Beidh an fógra neamhfhoilsithe go huathoibríoch ag an am seo
|
||||
scheduled_at: Fág bán chun an fógra a fhoilsiú láithreach
|
||||
starts_at: Roghnach. I gcás go bhfuil d'fhógra ceangailte le raon ama ar leith
|
||||
text: Is féidir leat comhréir phoist a úsáid. Tabhair aird ar an spás a ghlacfaidh an fógra ar scáileán an úsáideora
|
||||
appeal:
|
||||
text: Ní féidir leat achomharc a dhéanamh ach uair amháin ar stailc
|
||||
defaults:
|
||||
autofollow: Leanfaidh daoine a chláraíonn tríd an gcuireadh thú go huathoibríoch
|
||||
avatar: WEBP, PNG, GIF nó JPG. %{size} ar a mhéad. Íoslaghdófar é go %{dimensions}px
|
||||
bot: Cuir in iúl do dhaoine eile go ndéanann an cuntas gníomhartha uathoibrithe den chuid is mó agus go mb’fhéidir nach ndéanfar monatóireacht air
|
||||
context: Comhthéacs amháin nó comhthéacsanna iolracha inar cheart go mbeadh feidhm ag an scagaire
|
||||
current_password: Chun críocha slándála cuir isteach pasfhocal an chuntais reatha
|
||||
current_username: Le deimhniú, cuir isteach ainm úsáideora an chuntais reatha
|
||||
digest: Seoltar é tar éis tréimhse fhada neamhghníomhaíochta amháin agus sa chás sin amháin go bhfuil aon teachtaireachtaí pearsanta faighte agat agus tú as láthair
|
||||
email: Seolfar ríomhphost deimhnithe chugat
|
||||
header: WEBP, PNG, GIF nó JPG. %{size} ar a mhéad. Íoslaghdófar é go %{dimensions}px
|
||||
inbox_url: Cóipeáil an URL ó leathanach tosaigh an athsheachadáin is mian leat a úsáid
|
||||
irreversible: Imeoidh postálacha scagtha go dochúlaithe, fiú má bhaintear an scagaire níos déanaí
|
||||
locale: Teanga an chomhéadain úsáideora, r-phoist agus fógraí brú
|
||||
password: Úsáid ar a laghad 8 gcarachtar
|
||||
phrase: Déanfar é a mheaitseáil beag beann ar chásáil an téacs nó ar an ábhar atá ag tabhairt foláireamh do phostáil
|
||||
scopes: Cé na APIanna a mbeidh cead ag an bhfeidhmchlár rochtain a fháil orthu. Má roghnaíonn tú raon feidhme barrleibhéil, ní gá duit cinn aonair a roghnú.
|
||||
setting_aggregate_reblogs: Ná taispeáin treisithe nua do phoist a treisíodh le déanaí (ní dhéanann difear ach do threisithe nuafhaighte)
|
||||
setting_always_send_emails: Go hiondúil ní sheolfar fógraí ríomhphoist agus tú ag úsáid Mastodon go gníomhach
|
||||
setting_default_sensitive: Tá meáin íogair i bhfolach de réir réamhshocraithe agus is féidir iad a nochtadh le cliceáil
|
||||
setting_display_media_default: Folaigh meáin atá marcáilte mar íogair
|
||||
setting_display_media_hide_all: Folaigh meáin i gcónaí
|
||||
setting_display_media_show_all: Taispeáin meáin i gcónaí
|
||||
setting_use_blurhash: Tá grádáin bunaithe ar dhathanna na n-amharcanna ceilte ach cuireann siad salach ar aon mhionsonraí
|
||||
setting_use_pending_items: Folaigh nuashonruithe amlíne taobh thiar de chlic seachas an fotha a scrollú go huathoibríoch
|
||||
username: Is féidir leat litreacha, uimhreacha, agus béim a úsáid
|
||||
whole_word: Nuair a bhíonn an eochairfhocal nó frása alfa-uimhriúil amháin, ní chuirfear i bhfeidhm é ach amháin má mheaitseálann sé an focal iomlán
|
||||
domain_allow:
|
||||
domain: Beidh an fearann seo in ann sonraí a fháil ón bhfreastalaí seo agus déanfar sonraí a thagann isteach uaidh a phróiseáil agus a stóráil
|
||||
email_domain_block:
|
||||
domain: Is féidir gurb é seo an t-ainm fearainn a thaispeánann sa seoladh ríomhphoist nó sa taifead MX a úsáideann sé. Déanfar iad a sheiceáil nuair a chláraítear iad.
|
||||
with_dns_records: Déanfar iarracht taifid DNS an fhearainn tugtha a réiteach agus cuirfear bac ar na torthaí freisin
|
||||
featured_tag:
|
||||
name: 'Seo cuid de na hashtags a d’úsáid tú le déanaí:'
|
||||
filters:
|
||||
action: Roghnaigh an gníomh ba cheart a dhéanamh nuair a mheaitseálann postáil an scagaire
|
||||
actions:
|
||||
hide: Cuir an t-ábhar scagtha i bhfolach go hiomlán, ag iompar amhail is nach raibh sé ann
|
||||
warn: Folaigh an t-ábhar scagtha taobh thiar de rabhadh a luann teideal an scagaire
|
||||
form_admin_settings:
|
||||
activity_api_enabled: Áireamh na bpost a foilsíodh go háitiúil, úsáideoirí gníomhacha, agus clárúcháin nua i buicéid seachtainiúla
|
||||
app_icon: WEBP, PNG, GIF nó JPG. Sáraíonn sé an deilbhín réamhshocraithe aipe ar ghléasanna soghluaiste le deilbhín saincheaptha.
|
||||
backups_retention_period: Tá an cumas ag úsáideoirí cartlanna dá gcuid post a ghiniúint le híoslódáil níos déanaí. Nuair a bheidh luach dearfach socraithe, scriosfar na cartlanna seo go huathoibríoch ó do stór tar éis an líon sonraithe laethanta.
|
||||
bootstrap_timeline_accounts: Cuirfear na cuntais seo ar bharr na moltaí a leanann úsáideoirí nua.
|
||||
closed_registrations_message: Ar taispeáint nuair a dhúntar clárúcháin
|
||||
content_cache_retention_period: Scriosfar gach postáil ó fhreastalaithe eile (lena n-áirítear treisithe agus freagraí) tar éis an líon sonraithe laethanta, gan aird ar aon idirghníomhaíocht úsáideora áitiúil leis na postálacha sin. Áirítear leis seo postálacha ina bhfuil úsáideoir áitiúil tar éis é a mharcáil mar leabharmharcanna nó mar cheanáin. Caillfear tagairtí príobháideacha idir úsáideoirí ó chásanna éagsúla freisin agus ní féidir iad a athchóiriú. Tá úsáid an tsocraithe seo beartaithe le haghaidh cásanna sainchuspóra agus sáraítear go leor ionchais úsáideoirí nuair a chuirtear i bhfeidhm é le haghaidh úsáid ghinearálta.
|
||||
custom_css: Is féidir leat stíleanna saincheaptha a chur i bhfeidhm ar an leagan gréasáin de Mastodon.
|
||||
favicon: WEBP, PNG, GIF nó JPG. Sáraíonn sé an favicon Mastodon réamhshocraithe le deilbhín saincheaptha.
|
||||
mascot: Sáraíonn sé an léaráid san ardchomhéadan gréasáin.
|
||||
media_cache_retention_period: Déantar comhaid meán ó phoist a dhéanann cianúsáideoirí a thaisceadh ar do fhreastalaí. Nuair a bheidh luach dearfach socraithe, scriosfar na meáin tar éis an líon sonraithe laethanta. Má iarrtar na sonraí meán tar éis é a scriosadh, déanfar é a ath-íoslódáil, má tá an t-ábhar foinse fós ar fáil. Mar gheall ar shrianta ar cé chomh minic is atá cártaí réamhamhairc ag vótaíocht do shuíomhanna tríú páirtí, moltar an luach seo a shocrú go 14 lá ar a laghad, nó ní dhéanfar cártaí réamhamhairc naisc a nuashonrú ar éileamh roimh an am sin.
|
||||
peers_api_enabled: Liosta de na hainmneacha fearainn ar tháinig an freastalaí seo orthu sa choinbhleacht. Níl aon sonraí san áireamh anseo faoi cé acu an ndéanann tú cónascadh le freastalaí ar leith, díreach go bhfuil a fhios ag do fhreastalaí faoi. Úsáideann seirbhísí a bhailíonn staitisticí ar chónaidhm go ginearálta é seo.
|
||||
profile_directory: Liostaíonn an t-eolaire próifíle na húsáideoirí go léir a roghnaigh isteach le bheith in-aimsithe.
|
||||
require_invite_text: Nuair a bhíonn faomhadh láimhe ag teastáil le haghaidh clárúcháin, déan an "Cén fáth ar mhaith leat a bheith páirteach?" ionchur téacs éigeantach seachas roghnach
|
||||
site_contact_email: Conas is féidir le daoine dul i dteagmháil leat le haghaidh fiosrúchán dlíthiúil nó tacaíochta.
|
||||
site_contact_username: Conas is féidir le daoine dul i dteagmháil leat ar Mastodon.
|
||||
site_extended_description: Aon fhaisnéis bhreise a d’fhéadfadh a bheith úsáideach do chuairteoirí agus d’úsáideoirí. Is féidir é a struchtúrú le comhréir Markdown.
|
||||
site_short_description: Cur síos gairid chun cabhrú le do fhreastalaí a aithint go uathúil. Cé atá á rith, cé dó a bhfuil sé?
|
||||
site_terms: Bain úsáid as do pholasaí príobháideachta féin nó fág bán é chun an réamhshocrú a úsáid. Is féidir é a struchtúrú le comhréir Markdown.
|
||||
site_title: Conas is féidir le daoine tagairt a dhéanamh do do fhreastalaí seachas a ainm fearainn.
|
||||
status_page_url: URL leathanach inar féidir le daoine stádas an fhreastalaí seo a fheiceáil le linn briseadh amach
|
||||
theme: Téama a fheiceann cuairteoirí logáilte amach agus úsáideoirí nua.
|
||||
thumbnail: Íomhá thart ar 2:1 ar taispeáint taobh le faisnéis do fhreastalaí.
|
||||
timeline_preview: Beidh cuairteoirí logáilte amach in ann na postálacha poiblí is déanaí atá ar fáil ar an bhfreastalaí a bhrabhsáil.
|
||||
trendable_by_default: Léim ar athbhreithniú láimhe ar ábhar treochta. Is féidir míreanna aonair a bhaint as treochtaí fós tar éis an fhíric.
|
||||
trends: Léiríonn treochtaí cé na postálacha, hashtags agus scéalta nuachta atá ag tarraingt ar do fhreastalaí.
|
||||
trends_as_landing_page: Taispeáin inneachar treochta d'úsáideoirí agus do chuairteoirí atá logáilte amach in ionad cur síos ar an bhfreastalaí seo. Éilíonn treochtaí a chumasú.
|
||||
form_challenge:
|
||||
current_password: Tá tú ag dul isteach i limistéar slán
|
||||
imports:
|
||||
data: Comhad CSV easpórtáilte ó fhreastalaí Mastodon eile
|
||||
invite_request:
|
||||
text: Cabhróidh sé seo linn d’iarratas a athbhreithniú
|
||||
ip_block:
|
||||
comment: Roghnach. Cuimhnigh cén fáth ar chuir tú an riail seo leis.
|
||||
expires_in: Is acmhainn chríochta iad seoltaí IP, uaireanta roinntear iad agus is minic a athraíonn lámha. Ar an gcúis seo, ní mholtar bloic IP éiginnte.
|
||||
ip: Cuir isteach seoladh IPv4 nó IPv6. Is féidir leat raonta iomlána a bhlocáil ag baint úsáide as an chomhréir CIDR. Bí cúramach gan tú féin a ghlasáil amach!
|
||||
severities:
|
||||
no_access: Cuir bac ar rochtain ar na hacmhainní go léir
|
||||
sign_up_block: Ní bheidh clárú nua indéanta
|
||||
sign_up_requires_approval: Beidh do cheadú ag teastáil le haghaidh clárúcháin nua
|
||||
severity: Roghnaigh cad a tharlóidh le hiarratais ón IP seo
|
||||
rule:
|
||||
hint: Roghnach. Tabhair tuilleadh sonraí faoin riail
|
||||
text: Déan cur síos ar riail nó riachtanas d'úsáideoirí ar an bhfreastalaí seo. Déan iarracht é a choinneáil gearr agus simplí
|
||||
sessions:
|
||||
otp: 'Cuir isteach an cód dhá fhachtóir ginte ag d''aip ghutháin nó úsáid ceann de do chóid athshlánaithe:'
|
||||
webauthn: Más eochair USB atá ann déan cinnte é a chur isteach agus, más gá, tapáil í.
|
||||
settings:
|
||||
indexable: Seans go mbeidh do leathanach próifíle le feiceáil i dtorthaí cuardaigh ar Google, Bing agus eile.
|
||||
show_application: Beidh tú in ann a fheiceáil i gcónaí cén aip a d’fhoilsigh do phostáil beag beann ar.
|
||||
tag:
|
||||
name: Ní féidir leat ach cásáil na litreacha a athrú, mar shampla, chun é a dhéanamh níos inléite
|
||||
user:
|
||||
chosen_languages: Nuair a dhéantar iad a sheiceáil, ní thaispeánfar ach postálacha i dteangacha roghnaithe in amlínte poiblí
|
||||
role: Rialaíonn an ról na ceadanna atá ag an úsáideoir
|
||||
user_role:
|
||||
color: Dath le húsáid don ról ar fud an Chomhéadain, mar RGB i bhformáid heicsidheachúlach
|
||||
highlighted: Déanann sé seo an ról le feiceáil go poiblí
|
||||
name: Ainm poiblí an róil, má tá an ról socraithe le taispeáint mar shuaitheantas
|
||||
permissions_as_keys: Beidh rochtain ag úsáideoirí a bhfuil an ról seo acu ar...
|
||||
position: Cinneann ról níos airde réiteach coinbhleachta i gcásanna áirithe. Ní féidir gníomhartha áirithe a dhéanamh ach amháin ar róil a bhfuil tosaíocht níos ísle acu
|
||||
webhook:
|
||||
events: Roghnaigh imeachtaí le seoladh
|
||||
template: Cum do phálasta JSON féin ag baint úsáide as idirshuíomh athróg. Fág bán le haghaidh JSON réamhshocraithe.
|
||||
url: An áit a seolfar imeachtaí chuig
|
||||
labels:
|
||||
account:
|
||||
discoverable: Próifíl gné agus postálacha in halgartaim fionnachtana
|
||||
fields:
|
||||
name: Lipéad
|
||||
value: Ábhar
|
||||
indexable: Cuir postálacha poiblí san áireamh sna torthaí cuardaigh
|
||||
show_collections: Taispeáin seo a leanas agus leanúna ar phróifíl
|
||||
unlocked: Glac le leantóirí nua go huathoibríoch
|
||||
account_alias:
|
||||
acct: Láimhseáil an seanchuntais
|
||||
account_migration:
|
||||
acct: Láimhseáil an chuntais nua
|
||||
account_warning_preset:
|
||||
text: Téacs réamhshocraithe
|
||||
title: Teideal
|
||||
admin_account_action:
|
||||
include_statuses: Cuir postálacha tuairiscithe san áireamh sa ríomhphost
|
||||
send_email_notification: Cuir an t-úsáideoir ar an eolas trí ríomhphost
|
||||
text: Rabhadh saincheaptha
|
||||
type: Gníomh
|
||||
types:
|
||||
disable: Reoigh
|
||||
none: Seol rabhadh
|
||||
sensitive: Íogair
|
||||
silence: Teorannaigh
|
||||
suspend: Cuir ar fionraí
|
||||
warning_preset_id: Bain úsáid as réamhshocrú rabhaidh
|
||||
announcement:
|
||||
all_day: Imeacht uile-lae
|
||||
ends_at: Deireadh an imeachta
|
||||
scheduled_at: Foilsiú sceideal
|
||||
starts_at: Tús na hócáide
|
||||
text: Fógra
|
||||
appeal:
|
||||
text: Mínigh cén fáth ar cheart an cinneadh seo a fhreaschur
|
||||
defaults:
|
||||
autofollow: Tabhair cuireadh do chuntas a leanúint
|
||||
avatar: Abhatár
|
||||
bot: Is cuntas uathoibrithe é seo
|
||||
chosen_languages: Scag teangacha
|
||||
confirm_new_password: Deimhnigh pasfhocal nua
|
||||
confirm_password: Deimhnigh Pasfhocal
|
||||
context: Comhthéacsanna a scagadh
|
||||
current_password: Pasfhocal reatha
|
||||
data: Sonraí
|
||||
display_name: Ainm taispeána
|
||||
email: Seoladh ríomhphoist
|
||||
expires_in: In éag tar éis
|
||||
fields: Réimsí breise
|
||||
header: Ceanntásc
|
||||
honeypot: "%{label} (ná líon isteach)"
|
||||
inbox_url: URL an bhosca isteach sealaíochta
|
||||
irreversible: Droim ar aghaidh in ionad bheith ag folaigh
|
||||
locale: Teanga comhéadan
|
||||
max_uses: Uaslíon úsáidí
|
||||
new_password: Pasfhocal nua
|
||||
note: Beathaisnéis
|
||||
otp_attempt: Cód dhá-fhachtóir
|
||||
password: Pasfhocal
|
||||
phrase: Eochairfhocal nó frása
|
||||
setting_advanced_layout: Cumasaigh ardchomhéadan gréasáin
|
||||
setting_aggregate_reblogs: Treisithe grúpa i línte ama
|
||||
setting_always_send_emails: Seol fógraí ríomhphoist i gcónaí
|
||||
setting_auto_play_gif: Gifs beoite go huathoibríoch a imirt
|
||||
setting_boost_modal: Taispeáin dialóg deimhnithe roimh threisiú
|
||||
setting_default_language: Teanga postála
|
||||
setting_default_privacy: Postáil príobháideachta
|
||||
setting_default_sensitive: Marcáil na meáin mar íogair i gcónaí
|
||||
setting_delete_modal: Taispeáin dialóg deimhnithe sula scriostar postáil
|
||||
setting_disable_hover_cards: Díchumasaigh réamhamharc próifíle ar ainlíon
|
||||
setting_disable_swiping: Díchumasaigh gluaiseachtaí swiping
|
||||
setting_display_media: Taispeáint meáin
|
||||
setting_display_media_default: Réamhshocrú
|
||||
setting_display_media_hide_all: Cuir uile i bhfolach
|
||||
setting_display_media_show_all: Taispeáin uile
|
||||
setting_expand_spoilers: Méadaigh postálacha atá marcáilte le rabhaidh inneachair i gcónaí
|
||||
setting_hide_network: Folaigh do ghraf sóisialta
|
||||
setting_reduce_motion: Laghdú ar an tairiscint i beochan
|
||||
setting_system_font_ui: Úsáid cló réamhshocraithe an chórais
|
||||
setting_theme: Téama suímh
|
||||
setting_trends: Taispeáin treochtaí an lae inniu
|
||||
setting_unfollow_modal: Taispeáin dialóg deimhnithe sula ndíleanfaidh tú duine éigin
|
||||
setting_use_blurhash: Taispeáin grádáin ildaite do mheáin fholaithe
|
||||
setting_use_pending_items: Modh mall
|
||||
severity: Déine
|
||||
sign_in_token_attempt: Cód slándála
|
||||
title: Teideal
|
||||
type: Cineál iompórtála
|
||||
username: Ainm úsáideora
|
||||
username_or_email: Ainm Úsáideora nó Ríomhphost
|
||||
whole_word: Focal ar fad
|
||||
email_domain_block:
|
||||
with_dns_records: Cuir taifid MX agus IPanna an fhearainn san áireamh
|
||||
featured_tag:
|
||||
name: Haischlib
|
||||
filters:
|
||||
@ -50,27 +242,100 @@ ga:
|
||||
hide: Cuir i bhfolach go hiomlán
|
||||
warn: Cuir i bhfolach le rabhadh
|
||||
form_admin_settings:
|
||||
activity_api_enabled: Foilsigh staitisticí comhiomlána faoi ghníomhaíocht úsáideoirí san API
|
||||
app_icon: Deilbhín aip
|
||||
backups_retention_period: Tréimhse choinneála cartlainne úsáideora
|
||||
bootstrap_timeline_accounts: Mol na cuntais seo d'úsáideoirí nua i gcónaí
|
||||
closed_registrations_message: Teachtaireacht saincheaptha nuair nach bhfuil sínithe suas ar fáil
|
||||
content_cache_retention_period: Tréimhse choinneála inneachair cianda
|
||||
custom_css: CSS saincheaptha
|
||||
favicon: Favicon
|
||||
mascot: Mascóg saincheaptha (oidhreacht)
|
||||
media_cache_retention_period: Tréimhse choinneála taisce meán
|
||||
peers_api_enabled: Foilsigh liosta de na freastalaithe aimsithe san API
|
||||
profile_directory: Cumasaigh eolaire próifíle
|
||||
registrations_mode: Cé atá in ann clárú
|
||||
require_invite_text: A cheangal ar chúis a bheith páirteach
|
||||
show_domain_blocks: Taispeáin bloic fearainn
|
||||
show_domain_blocks_rationale: Taispeáin cén fáth ar cuireadh bac ar fhearann
|
||||
site_contact_email: R-phost teagmhála
|
||||
site_contact_username: Ainm úsáideora teagmhála
|
||||
site_extended_description: Cur síos fada
|
||||
site_short_description: Cur síos freastalaí
|
||||
site_terms: Polasaí príobháideachais
|
||||
site_title: Ainm freastalaí
|
||||
status_page_url: URL an leathanaigh stádais
|
||||
theme: Téama réamhshocraithe
|
||||
thumbnail: Mionsamhail freastalaí
|
||||
timeline_preview: Ceadaigh rochtain neamhdheimhnithe ar amlínte poiblí
|
||||
trendable_by_default: Ceadaigh treochtaí gan athbhreithniú roimh ré
|
||||
trends: Cumasaigh treochtaí
|
||||
trends_as_landing_page: Úsáid treochtaí mar an leathanach tuirlingthe
|
||||
interactions:
|
||||
must_be_follower: Cuir bac ar fhógraí ó dhaoine nach leantóirí iad
|
||||
must_be_following: Cuir bac ar fhógraí ó dhaoine nach leanann tú
|
||||
must_be_following_dm: Cuir bac ar theachtaireachtaí díreacha ó dhaoine nach leanann tú
|
||||
invite:
|
||||
comment: Ráiteas
|
||||
invite_request:
|
||||
text: Cén fáth ar mhaith leat a bheith páirteach?
|
||||
ip_block:
|
||||
comment: Ráiteas
|
||||
ip: IP
|
||||
severities:
|
||||
no_access: Rochtain a bhlocáil
|
||||
sign_up_block: Cuir bac ar chlárúcháin
|
||||
sign_up_requires_approval: Teorainn le clárú
|
||||
severity: Riail
|
||||
notification_emails:
|
||||
appeal: Déanann duine éigin achomharc i gcoinne chinneadh modhnóra
|
||||
digest: Seol r-phoist achoimre
|
||||
favourite: Is fearr le duine éigin do phostáil
|
||||
follow: Lean duine éigin tú
|
||||
follow_request: D'iarr duine éigin tú a leanúint
|
||||
mention: Luaigh duine éigin tú
|
||||
pending_account: Ní mór athbhreithniú a dhéanamh ar chuntas nua
|
||||
reblog: Mhol duine éigin do phostáil
|
||||
report: Tá tuairisc nua curtha isteach
|
||||
software_updates:
|
||||
all: Fógra a thabhairt ar gach nuashonrú
|
||||
critical: Fógra a thabhairt ar nuashonruithe ríthábhachtacha amháin
|
||||
label: Tá leagan nua Mastodon ar fáil
|
||||
none: Ná cuir nuashonruithe ar an eolas choíche (ní mholtar é)
|
||||
patch: Fógra ar nuashonruithe bugfix
|
||||
trending_tag: Teastaíonn athbhreithniú ar threocht nua
|
||||
rule:
|
||||
hint: Eolas breise
|
||||
text: Riail
|
||||
settings:
|
||||
indexable: Cuir leathanach próifíle san innill chuardaigh
|
||||
show_application: Taispeáin cén aip ónar sheol tú postáil
|
||||
tag:
|
||||
listable: Lig don hashchlib seo a bheith le feiceáil i gcuardach agus i moltaí
|
||||
name: Haischlib
|
||||
trendable: Lig don haischlib seo a bheith le feiceáil faoi threochtaí
|
||||
usable: Lig do phostálacha an hashchlib seo a úsáid
|
||||
user:
|
||||
role: Ról
|
||||
time_zone: Crios ama
|
||||
user_role:
|
||||
color: Dath suaitheantas
|
||||
highlighted: Taispeáin ról mar shuaitheantas ar phróifílí úsáideora
|
||||
name: Ainm
|
||||
permissions_as_keys: Ceadanna
|
||||
position: Tosaíocht
|
||||
webhook:
|
||||
events: Imeachtaí cumasaithe
|
||||
template: Teimpléad pá-ualach
|
||||
url: URL críochphointe
|
||||
'no': Níl
|
||||
not_recommended: Ní mholtar
|
||||
overridden: Sáraithe
|
||||
recommended: Molta
|
||||
required:
|
||||
mark: "*"
|
||||
text: ag teastáil
|
||||
title:
|
||||
sessions:
|
||||
webauthn: Úsáid ceann de d'eochracha slándála chun síniú isteach
|
||||
'yes': Tá
|
||||
|
@ -663,6 +663,7 @@ sl:
|
||||
report: 'Prijavi #%{id}'
|
||||
reported_account: Prijavljeni račun
|
||||
reported_by: Prijavil/a
|
||||
reported_with_application: Prijavljeno s programom
|
||||
resolved: Razrešeni
|
||||
resolved_msg: Prijava je uspešno razrešena!
|
||||
skip_to_actions: Preskoči na dejanja
|
||||
|
@ -639,6 +639,7 @@ tr:
|
||||
report: 'Şikayet #%{id}'
|
||||
reported_account: Şikayet edilen hesap
|
||||
reported_by: Şikayet eden
|
||||
reported_with_application: Uygulamayla bildirildi
|
||||
resolved: Giderildi
|
||||
resolved_msg: Şikayet başarıyla çözümlendi!
|
||||
skip_to_actions: İşlemlere atla
|
||||
|
@ -30,6 +30,7 @@ Rails.application.routes.draw do
|
||||
/lists/(*any)
|
||||
/links/(*any)
|
||||
/notifications/(*any)
|
||||
/notifications_v2/(*any)
|
||||
/favourites
|
||||
/bookmarks
|
||||
/pinned
|
||||
|
@ -126,6 +126,7 @@
|
||||
"tesseract.js": "^2.1.5",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"twitter-text": "3.1.0",
|
||||
"use-debounce": "^10.0.0",
|
||||
"webpack": "^4.47.0",
|
||||
"webpack-assets-manifest": "^4.0.6",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
|
@ -64,7 +64,7 @@ describe Admin::ReportsController do
|
||||
|
||||
describe 'POST #reopen' do
|
||||
it 'reopens the report' do
|
||||
report = Fabricate(:report)
|
||||
report = Fabricate(:report, action_taken_at: 3.days.ago)
|
||||
|
||||
put :reopen, params: { id: report }
|
||||
expect(response).to redirect_to(admin_report_path(report))
|
||||
@ -89,7 +89,7 @@ describe Admin::ReportsController do
|
||||
|
||||
describe 'POST #unassign' do
|
||||
it 'reopens the report' do
|
||||
report = Fabricate(:report)
|
||||
report = Fabricate(:report, assigned_account_id: Account.last.id)
|
||||
|
||||
put :unassign, params: { id: report }
|
||||
expect(response).to redirect_to(admin_report_path(report))
|
||||
|
@ -90,7 +90,7 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
media.destroy
|
||||
end
|
||||
|
||||
it 'saves media attachment with correct file metadata' do
|
||||
it 'saves media attachment with correct file and size metadata' do
|
||||
expect(media)
|
||||
.to be_persisted
|
||||
.and be_processing_complete
|
||||
@ -103,14 +103,12 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
|
||||
# Rack::Mime (used by PublicFileServerMiddleware) recognizes file extension
|
||||
expect(Rack::Mime.mime_type(extension, nil)).to eq content_type
|
||||
end
|
||||
|
||||
it 'saves media attachment with correct size metadata' do
|
||||
# strips original file name
|
||||
# Strip original file name
|
||||
expect(media.file_file_name)
|
||||
.to_not start_with '600x400'
|
||||
|
||||
# sets meta for original and thumbnail
|
||||
# Set meta for original and thumbnail
|
||||
expect(media.file.meta.deep_symbolize_keys)
|
||||
.to include(
|
||||
original: include(
|
||||
@ -174,10 +172,18 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('avatar.gif')) }
|
||||
|
||||
it 'sets correct file metadata' do
|
||||
expect(media.type).to eq 'gifv'
|
||||
expect(media.file_content_type).to eq 'video/mp4'
|
||||
expect(media.file.meta['original']['width']).to eq 128
|
||||
expect(media.file.meta['original']['height']).to eq 128
|
||||
expect(media)
|
||||
.to have_attributes(
|
||||
type: eq('gifv'),
|
||||
file_content_type: eq('video/mp4')
|
||||
)
|
||||
expect(media_metadata)
|
||||
.to include(
|
||||
original: include(
|
||||
width: eq(128),
|
||||
height: eq(128)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@ -192,11 +198,19 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
let(:media) { Fabricate(:media_attachment, file: attachment_fixture(fixture[:filename])) }
|
||||
|
||||
it 'sets correct file metadata' do
|
||||
expect(media.type).to eq 'image'
|
||||
expect(media.file_content_type).to eq 'image/gif'
|
||||
expect(media.file.meta['original']['width']).to eq fixture[:width]
|
||||
expect(media.file.meta['original']['height']).to eq fixture[:height]
|
||||
expect(media.file.meta['original']['aspect']).to eq fixture[:aspect]
|
||||
expect(media)
|
||||
.to have_attributes(
|
||||
type: eq('image'),
|
||||
file_content_type: eq('image/gif')
|
||||
)
|
||||
expect(media_metadata)
|
||||
.to include(
|
||||
original: include(
|
||||
width: eq(fixture[:width]),
|
||||
height: eq(fixture[:height]),
|
||||
aspect: eq(fixture[:aspect])
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -204,39 +218,42 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
|
||||
describe 'ogg with cover art' do
|
||||
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) }
|
||||
let(:expected_media_duration) { 0.235102 }
|
||||
|
||||
# The libvips and ImageMagick implementations produce different results
|
||||
let(:expected_background_color) { Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4' }
|
||||
|
||||
it 'sets correct file metadata' do
|
||||
expect(media.type).to eq 'audio'
|
||||
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
|
||||
expect(media.thumbnail.present?).to be true
|
||||
expect(media)
|
||||
.to have_attributes(
|
||||
type: eq('audio'),
|
||||
thumbnail: be_present,
|
||||
file_file_name: not_eq('boop.ogg')
|
||||
)
|
||||
|
||||
expect(media.file.meta['colors']['background']).to eq(expected_background_color)
|
||||
expect(media.file_file_name).to_not eq 'boop.ogg'
|
||||
end
|
||||
|
||||
def expected_background_color
|
||||
# The libvips and ImageMagick implementations produce different results
|
||||
Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4'
|
||||
expect(media_metadata)
|
||||
.to include(
|
||||
original: include(duration: be_within(0.05).of(expected_media_duration)),
|
||||
colors: include(background: eq(expected_background_color))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mp3 with large cover art' do
|
||||
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.mp3')) }
|
||||
let(:expected_media_duration) { 0.235102 }
|
||||
|
||||
it 'detects it as an audio file' do
|
||||
expect(media.type).to eq 'audio'
|
||||
end
|
||||
|
||||
it 'sets meta for the duration' do
|
||||
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
|
||||
end
|
||||
|
||||
it 'extracts thumbnail' do
|
||||
expect(media.thumbnail.present?).to be true
|
||||
end
|
||||
|
||||
it 'gives the file a random name' do
|
||||
expect(media.file_file_name).to_not eq 'boop.mp3'
|
||||
it 'detects file type and sets correct metadata' do
|
||||
expect(media)
|
||||
.to have_attributes(
|
||||
type: eq('audio'),
|
||||
thumbnail: be_present,
|
||||
file_file_name: not_eq('boop.mp3')
|
||||
)
|
||||
expect(media_metadata)
|
||||
.to include(
|
||||
original: include(duration: be_within(0.05).of(expected_media_duration))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@ -274,4 +291,10 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
expect(media.valid?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def media_metadata
|
||||
media.file.meta.deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
|
@ -161,6 +161,7 @@ RSpec::Sidekiq.configure do |config|
|
||||
end
|
||||
|
||||
RSpec::Matchers.define_negated_matcher :not_change, :change
|
||||
RSpec::Matchers.define_negated_matcher :not_eq, :eq
|
||||
RSpec::Matchers.define_negated_matcher :not_include, :include
|
||||
|
||||
def request_fixture(name)
|
||||
|
@ -40,14 +40,13 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
it 'updates text' do
|
||||
it 'updates text and content warning' do
|
||||
subject.call(status, json, json)
|
||||
expect(status.reload.text).to eq 'Hello universe'
|
||||
end
|
||||
|
||||
it 'updates content warning' do
|
||||
subject.call(status, json, json)
|
||||
expect(status.reload.spoiler_text).to eq 'Show more'
|
||||
expect(status.reload)
|
||||
.to have_attributes(
|
||||
text: eq('Hello universe'),
|
||||
spoiler_text: eq('Show more')
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the changes are only in sanitized-out HTML' do
|
||||
@ -67,12 +66,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits and does not mark status edited' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.edited?).to be false
|
||||
expect(status).to_not be_edited
|
||||
end
|
||||
end
|
||||
|
||||
@ -90,15 +86,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits, mark status edited, or update text' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.reload.edited?).to be false
|
||||
end
|
||||
|
||||
it 'does not update the text' do
|
||||
expect(status.reload).to_not be_edited
|
||||
expect(status.reload.text).to eq 'Hello world'
|
||||
end
|
||||
end
|
||||
@ -137,19 +127,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits, mark status edited, update text but does update tallies' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.reload.edited?).to be false
|
||||
end
|
||||
|
||||
it 'does not update the text' do
|
||||
expect(status.reload).to_not be_edited
|
||||
expect(status.reload.text).to eq 'Hello world'
|
||||
end
|
||||
|
||||
it 'updates tallies' do
|
||||
expect(status.poll.reload.cached_tallies).to eq [4, 3]
|
||||
end
|
||||
end
|
||||
@ -189,19 +170,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits, mark status edited, update text, or update tallies' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.reload.edited?).to be false
|
||||
end
|
||||
|
||||
it 'does not update the text' do
|
||||
expect(status.reload).to_not be_edited
|
||||
expect(status.reload.text).to eq 'Hello world'
|
||||
end
|
||||
|
||||
it 'does not update tallies' do
|
||||
expect(status.poll.reload.cached_tallies).to eq [0, 0]
|
||||
end
|
||||
end
|
||||
@ -213,13 +185,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
status.snapshot!(rate_limit: false)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
expect { subject.call(status, json, json) }.to_not(change { status.reload.edits.pluck(&:id) })
|
||||
end
|
||||
|
||||
it 'does not update the text, spoiler_text or edited_at' do
|
||||
it 'does not create any edits or update relevant attributes' do
|
||||
expect { subject.call(status, json, json) }
|
||||
.to_not(change { status.reload.attributes.slice('text', 'spoiler_text', 'edited_at').values })
|
||||
.to not_change { status.reload.edits.pluck(&:id) }
|
||||
.and(not_change { status.reload.attributes.slice('text', 'spoiler_text', 'edited_at').values })
|
||||
end
|
||||
end
|
||||
|
||||
@ -237,12 +206,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits or mark status edited' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.edited?).to be false
|
||||
expect(status).to_not be_edited
|
||||
end
|
||||
end
|
||||
|
||||
@ -261,12 +227,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'does not create any edits' do
|
||||
it 'does not create any edits or mark status edited' do
|
||||
expect(status.reload.edits).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark status as edited' do
|
||||
expect(status.edited?).to be false
|
||||
expect(status).to_not be_edited
|
||||
end
|
||||
end
|
||||
|
||||
@ -412,11 +375,8 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'removes poll' do
|
||||
it 'removes poll and records media change in edit' do
|
||||
expect(status.reload.poll).to be_nil
|
||||
end
|
||||
|
||||
it 'records media change in edit' do
|
||||
expect(status.edits.reload.last.poll_options).to be_nil
|
||||
end
|
||||
end
|
||||
@ -442,26 +402,21 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
subject.call(status, json, json)
|
||||
end
|
||||
|
||||
it 'creates a poll' do
|
||||
it 'creates a poll and records media change in edit' do
|
||||
poll = status.reload.poll
|
||||
|
||||
expect(poll).to_not be_nil
|
||||
expect(poll.options).to eq %w(Foo Bar Baz)
|
||||
end
|
||||
|
||||
it 'records media change in edit' do
|
||||
expect(status.edits.reload.last.poll_options).to eq %w(Foo Bar Baz)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates edit history' do
|
||||
it 'creates edit history and sets edit timestamp' do
|
||||
subject.call(status, json, json)
|
||||
expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe']
|
||||
end
|
||||
|
||||
it 'sets edited timestamp' do
|
||||
subject.call(status, json, json)
|
||||
expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
|
||||
expect(status.edits.reload.map(&:text))
|
||||
.to eq ['Hello world', 'Hello universe']
|
||||
expect(status.reload.edited_at.to_s)
|
||||
.to eq '2021-09-08 22:39:25 UTC'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -34,21 +34,14 @@ RSpec.describe FanOutOnWriteService do
|
||||
context 'when status is public' do
|
||||
let(:visibility) { 'public' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
it 'adds status to home feed of author and followers and broadcasts', :inline_jobs do
|
||||
expect(status.id)
|
||||
.to be_in(home_feed_of(alice))
|
||||
.and be_in(home_feed_of(bob))
|
||||
.and be_in(home_feed_of(tom))
|
||||
|
||||
it 'is added to the home feed of a follower', :inline_jobs do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
expect(home_feed_of(tom)).to include status.id
|
||||
end
|
||||
|
||||
it 'is broadcast to the hashtag stream' do
|
||||
expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(redis).to have_received(:publish).with('timeline:hashtag:hoge:local', anything)
|
||||
end
|
||||
|
||||
it 'is broadcast to the public stream' do
|
||||
expect(redis).to have_received(:publish).with('timeline:public', anything)
|
||||
expect(redis).to have_received(:publish).with('timeline:public:local', anything)
|
||||
expect(redis).to have_received(:publish).with('timeline:public:media', anything)
|
||||
@ -58,60 +51,41 @@ RSpec.describe FanOutOnWriteService do
|
||||
context 'when status is limited' do
|
||||
let(:visibility) { 'limited' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
it 'adds status to home feed of author and mentioned followers and does not broadcast', :inline_jobs do
|
||||
expect(status.id)
|
||||
.to be_in(home_feed_of(alice))
|
||||
.and be_in(home_feed_of(bob))
|
||||
expect(status.id)
|
||||
.to_not be_in(home_feed_of(tom))
|
||||
|
||||
it 'is added to the home feed of the mentioned follower', :inline_jobs do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not added to the home feed of the other follower' do
|
||||
expect(home_feed_of(tom)).to_not include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
|
||||
expect_no_broadcasting
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is private' do
|
||||
let(:visibility) { 'private' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
it 'adds status to home feed of author and followers and does not broadcast', :inline_jobs do
|
||||
expect(status.id)
|
||||
.to be_in(home_feed_of(alice))
|
||||
.and be_in(home_feed_of(bob))
|
||||
.and be_in(home_feed_of(tom))
|
||||
|
||||
it 'is added to the home feed of a follower', :inline_jobs do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
expect(home_feed_of(tom)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
|
||||
expect_no_broadcasting
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is direct' do
|
||||
let(:visibility) { 'direct' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
it 'is added to the home feed of its author and mentioned followers and does not broadcast', :inline_jobs do
|
||||
expect(status.id)
|
||||
.to be_in(home_feed_of(alice))
|
||||
.and be_in(home_feed_of(bob))
|
||||
expect(status.id)
|
||||
.to_not be_in(home_feed_of(tom))
|
||||
|
||||
it 'is added to the home feed of the mentioned follower', :inline_jobs do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not added to the home feed of the other follower' do
|
||||
expect(home_feed_of(tom)).to_not include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
|
||||
expect_no_broadcasting
|
||||
end
|
||||
|
||||
context 'when handling status updates' do
|
||||
@ -131,4 +105,13 @@ RSpec.describe FanOutOnWriteService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expect_no_broadcasting
|
||||
expect(redis)
|
||||
.to_not have_received(:publish)
|
||||
.with('timeline:hashtag:hoge', anything)
|
||||
expect(redis)
|
||||
.to_not have_received(:publish)
|
||||
.with('timeline:public', anything)
|
||||
end
|
||||
end
|
||||
|
10
yarn.lock
10
yarn.lock
@ -2913,6 +2913,7 @@ __metadata:
|
||||
tiny-queue: "npm:^0.2.1"
|
||||
twitter-text: "npm:3.1.0"
|
||||
typescript: "npm:^5.0.4"
|
||||
use-debounce: "npm:^10.0.0"
|
||||
webpack: "npm:^4.47.0"
|
||||
webpack-assets-manifest: "npm:^4.0.6"
|
||||
webpack-bundle-analyzer: "npm:^4.8.0"
|
||||
@ -17567,6 +17568,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-debounce@npm:^10.0.0":
|
||||
version: 10.0.1
|
||||
resolution: "use-debounce@npm:10.0.1"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/377a11814a708f5c392f465cbbe2d119a8a2635c8226cc5e30eba397c4436f8e8234385d069467b369d105ed0d3be733c6a08d8ae1004017c6d6f58f4d4c24d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
|
||||
|
Loading…
Reference in New Issue
Block a user