mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-12-28 20:55:37 +01:00
Merge branch 'refs/heads/glitch' into develop
This commit is contained in:
commit
084cfb7d6c
320 changed files with 4785 additions and 1820 deletions
|
@ -211,7 +211,7 @@ FROM build AS ffmpeg
|
|||
|
||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||
ARG FFMPEG_VERSION=7.0.1
|
||||
ARG FFMPEG_VERSION=7.0.2
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -16,7 +16,7 @@ gem 'pghero'
|
|||
|
||||
gem 'aws-sdk-s3', '~> 1.123', require: false
|
||||
gem 'blurhash', '~> 0.1'
|
||||
gem 'fog-core', '<= 2.4.0'
|
||||
gem 'fog-core', '<= 2.5.0'
|
||||
gem 'fog-openstack', '~> 1.0', require: false
|
||||
gem 'kt-paperclip', '~> 7.2'
|
||||
gem 'md-paperclip-azure', '~> 2.2', require: false
|
||||
|
|
22
Gemfile.lock
22
Gemfile.lock
|
@ -100,8 +100,8 @@ GEM
|
|||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.950.0)
|
||||
aws-sdk-core (3.201.0)
|
||||
aws-partitions (1.961.0)
|
||||
aws-sdk-core (3.201.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
|
@ -109,11 +109,11 @@ GEM
|
|||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-s3 (1.157.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
|
@ -135,7 +135,7 @@ GEM
|
|||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.7)
|
||||
bootsnap (1.18.3)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
racc
|
||||
|
@ -229,7 +229,7 @@ GEM
|
|||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (0.110.0)
|
||||
excon (0.111.0)
|
||||
fabrication (2.31.0)
|
||||
faker (3.4.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
|
@ -269,7 +269,7 @@ GEM
|
|||
flatware-rspec (2.3.2)
|
||||
flatware (= 2.3.2)
|
||||
rspec (>= 3.6)
|
||||
fog-core (2.4.0)
|
||||
fog-core (2.5.0)
|
||||
builder
|
||||
excon (~> 0.71)
|
||||
formatador (>= 0.2, < 2.0)
|
||||
|
@ -429,7 +429,7 @@ GEM
|
|||
memory_profiler (1.0.2)
|
||||
mime-types (3.5.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.0604)
|
||||
mime-types-data (3.2024.0702)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.24.1)
|
||||
|
@ -758,7 +758,7 @@ GEM
|
|||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.0.3)
|
||||
rubocop-rspec (3.0.4)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
|
@ -796,7 +796,7 @@ GEM
|
|||
redis (>= 4.5.0, < 5)
|
||||
sidekiq-bulk (0.2.0)
|
||||
sidekiq
|
||||
sidekiq-scheduler (5.0.5)
|
||||
sidekiq-scheduler (5.0.6)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 6, < 8)
|
||||
tilt (>= 1.4.0, < 3)
|
||||
|
@ -945,7 +945,7 @@ DEPENDENCIES
|
|||
fast_blank (~> 1.0)
|
||||
fastimage
|
||||
flatware-rspec
|
||||
fog-core (<= 2.4.0)
|
||||
fog-core (<= 2.5.0)
|
||||
fog-openstack (~> 1.0)
|
||||
fuubar (~> 2.5)
|
||||
haml-rails (~> 2.0)
|
||||
|
|
|
@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController
|
|||
before_action :set_policy
|
||||
|
||||
def show
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
render json: @policy, serializer: REST::V1::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@policy.update!(resource_params)
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
render json: @policy, serializer: REST::V1::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -29,7 +29,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
|
|||
end
|
||||
|
||||
def dismiss
|
||||
@request.destroy!
|
||||
DismissNotificationRequestService.new.call(@request)
|
||||
render_empty
|
||||
end
|
||||
|
||||
|
|
38
app/controllers/api/v2/notifications/policies_controller.rb
Normal file
38
app/controllers/api/v2/notifications/policies_controller.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::Notifications::PoliciesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_policy
|
||||
|
||||
def show
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@policy.update!(resource_params)
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_policy
|
||||
@policy = NotificationPolicy.find_or_initialize_by(account: current_account)
|
||||
|
||||
with_read_replica do
|
||||
@policy.summarize!
|
||||
end
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(
|
||||
:for_not_following,
|
||||
:for_not_followers,
|
||||
:for_new_accounts,
|
||||
:for_private_mentions,
|
||||
:for_limited_accounts
|
||||
)
|
||||
end
|
||||
end
|
|
@ -16,10 +16,10 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
|||
@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)
|
||||
@presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param)
|
||||
|
||||
# Preload associations to avoid N+1s
|
||||
ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
|
||||
ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call
|
||||
end
|
||||
|
||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
|
||||
|
@ -27,14 +27,14 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
|||
|
||||
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.account.count' => @presenter.accounts.size,
|
||||
'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size,
|
||||
'app.notification_grouping.status.count' => statuses.size,
|
||||
'app.notification_grouping.status.unique_count' => statuses.uniq.size
|
||||
'app.notification_grouping.status.unique_count' => statuses.uniq.size,
|
||||
'app.notification_grouping.expand_accounts_param' => expand_accounts_param
|
||||
)
|
||||
|
||||
presenter = GroupedNotificationsPresenter.new(@grouped_notifications)
|
||||
render json: presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
|
||||
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata, expand_accounts: expand_accounts_param
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -131,4 +131,15 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
|||
def pagination_params(core_params)
|
||||
params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params)
|
||||
end
|
||||
|
||||
def expand_accounts_param
|
||||
case params[:expand_accounts]
|
||||
when nil, 'full'
|
||||
'full'
|
||||
when 'partial_avatars'
|
||||
'partial_avatars'
|
||||
else
|
||||
raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -116,7 +116,7 @@ module ApplicationHelper
|
|||
def material_symbol(icon, attributes = {})
|
||||
inline_svg_tag(
|
||||
"400-24px/#{icon}.svg",
|
||||
class: %w(icon).concat(attributes[:class].to_s.split),
|
||||
class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split),
|
||||
role: :img
|
||||
)
|
||||
end
|
||||
|
@ -127,23 +127,23 @@ module ApplicationHelper
|
|||
|
||||
def visibility_icon(status)
|
||||
if status.public_visibility?
|
||||
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
|
||||
material_symbol('globe', title: I18n.t('statuses.visibilities.public'))
|
||||
elsif status.unlisted_visibility?
|
||||
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
|
||||
material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted'))
|
||||
elsif status.private_visibility? || status.limited_visibility?
|
||||
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
|
||||
material_symbol('lock', title: I18n.t('statuses.visibilities.private'))
|
||||
elsif status.direct_visibility?
|
||||
fa_icon('at', title: I18n.t('statuses.visibilities.direct'))
|
||||
material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct'))
|
||||
end
|
||||
end
|
||||
|
||||
def interrelationships_icon(relationships, account_id)
|
||||
if relationships.following[account_id] && relationships.followed_by[account_id]
|
||||
fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive')
|
||||
material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive')
|
||||
elsif relationships.following[account_id]
|
||||
fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active')
|
||||
material_symbol(locale_direction == 'ltr' ? 'arrow_right_alt' : 'arrow_left_alt', title: I18n.t('relationships.following'), class: 'active')
|
||||
elsif relationships.followed_by[account_id]
|
||||
fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive')
|
||||
material_symbol(locale_direction == 'ltr' ? 'arrow_left_alt' : 'arrow_right_alt', title: I18n.t('relationships.followers'), class: 'passive')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -60,13 +60,13 @@ module StatusesHelper
|
|||
def fa_visibility_icon(status)
|
||||
case status.visibility
|
||||
when 'public'
|
||||
fa_icon 'globe fw'
|
||||
material_symbol 'globe'
|
||||
when 'unlisted'
|
||||
fa_icon 'unlock fw'
|
||||
material_symbol 'lock_open'
|
||||
when 'private'
|
||||
fa_icon 'lock fw'
|
||||
material_symbol 'lock'
|
||||
when 'direct'
|
||||
fa_icon 'at fw'
|
||||
material_symbol 'alternate_email'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -431,6 +431,42 @@ Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => {
|
|||
target.src = target.dataset.static;
|
||||
});
|
||||
|
||||
const setInputDisabled = (
|
||||
input: HTMLInputElement | HTMLSelectElement,
|
||||
disabled: boolean,
|
||||
) => {
|
||||
input.disabled = disabled;
|
||||
|
||||
const wrapper = input.closest('.with_label');
|
||||
if (wrapper) {
|
||||
wrapper.classList.toggle('disabled', input.disabled);
|
||||
|
||||
const hidden =
|
||||
input.type === 'checkbox' &&
|
||||
wrapper.querySelector<HTMLInputElement>('input[type=hidden][value="0"]');
|
||||
if (hidden) {
|
||||
hidden.disabled = input.disabled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#account_statuses_cleanup_policy_enabled',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement) || !target.form) return;
|
||||
|
||||
target.form
|
||||
.querySelectorAll<
|
||||
HTMLInputElement | HTMLSelectElement
|
||||
>('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select')
|
||||
.forEach((input) => {
|
||||
setInputDisabled(input, !target.checked);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
|
|
|
@ -77,6 +77,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS
|
|||
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
|
||||
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
||||
|
@ -585,6 +593,62 @@ export const dismissNotificationRequestFail = (id, error) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequests = (ids) => (dispatch, getState) => {
|
||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
||||
dispatch(acceptNotificationRequestsRequest(ids));
|
||||
|
||||
api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => {
|
||||
dispatch(acceptNotificationRequestsSuccess(ids));
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
}).catch(err => {
|
||||
dispatch(acceptNotificationRequestFail(ids, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptNotificationRequestsRequest = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequestsSuccess = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequestsFail = (ids, error) => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_FAIL,
|
||||
ids,
|
||||
error,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequests = (ids) => (dispatch, getState) => {
|
||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
||||
dispatch(acceptNotificationRequestsRequest(ids));
|
||||
|
||||
api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => {
|
||||
dispatch(dismissNotificationRequestsSuccess(ids));
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
}).catch(err => {
|
||||
dispatch(dismissNotificationRequestFail(ids, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissNotificationRequestsRequest = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequestsSuccess = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequestsFail = (ids, error) => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_FAIL,
|
||||
ids,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
|
||||
const current = getState().getIn(['notificationRequests', 'current']);
|
||||
const params = { account_id: accountId };
|
||||
|
|
|
@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api';
|
|||
import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies';
|
||||
|
||||
export const apiGetNotificationPolicy = () =>
|
||||
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
|
||||
apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
|
||||
|
||||
export const apiUpdateNotificationsPolicy = (
|
||||
policy: Partial<NotificationPolicyJSON>,
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v2/notifications/policy', policy);
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
// See app/serializers/rest/notification_policy_serializer.rb
|
||||
|
||||
export type NotificationPolicyValue = 'accept' | 'filter' | 'drop';
|
||||
|
||||
export interface NotificationPolicyJSON {
|
||||
filter_not_following: boolean;
|
||||
filter_not_followers: boolean;
|
||||
filter_new_accounts: boolean;
|
||||
filter_private_mentions: boolean;
|
||||
for_not_following: NotificationPolicyValue;
|
||||
for_not_followers: NotificationPolicyValue;
|
||||
for_new_accounts: NotificationPolicyValue;
|
||||
for_private_mentions: NotificationPolicyValue;
|
||||
for_limited_accounts: NotificationPolicyValue;
|
||||
summary: {
|
||||
pending_requests_count: number;
|
||||
pending_notifications_count: number;
|
||||
|
|
|
@ -106,7 +106,7 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react';
|
||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
@ -7,6 +8,7 @@ import { Icon } from './icon';
|
|||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
|
@ -16,6 +18,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
name,
|
||||
value,
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
|
@ -29,8 +32,14 @@ export const CheckBox: React.FC<Props> = ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('check-box__input', { checked })}>
|
||||
{checked && <Icon id='check' icon={DoneIcon} />}
|
||||
<span
|
||||
className={classNames('check-box__input', { checked, indeterminate })}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
|
||||
) : (
|
||||
checked && <Icon id='check' icon={DoneIcon} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
|
|
185
app/javascript/flavours/glitch/components/dropdown_selector.tsx
Normal file
185
app/javascript/flavours/glitch/components/dropdown_selector.tsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents
|
||||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
meta: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
classNamePrefix: string;
|
||||
style?: React.CSSProperties;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DropdownSelector: React.FC<Props> = ({
|
||||
style,
|
||||
items,
|
||||
value,
|
||||
classNamePrefix = 'privacy-dropdown',
|
||||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const nodeRef = useRef<HTMLUListElement>(null);
|
||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
nodeRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, onClose],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||
) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
if (value) onChange(value);
|
||||
},
|
||||
[onClose, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex((item) => item.value === value);
|
||||
|
||||
let element: Element | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
const selectedValue = element.getAttribute('data-index');
|
||||
element.focus();
|
||||
if (selectedValue) setCurrentValue(selectedValue);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener(
|
||||
'touchend',
|
||||
handleDocumentClick,
|
||||
listenerOptions,
|
||||
);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={style} role='listbox' ref={nodeRef}>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames(`${classNamePrefix}__option`, {
|
||||
active: item.value === currentValue,
|
||||
})}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
{item.icon && item.iconComponent && (
|
||||
<div className={`${classNamePrefix}__option__icon`}>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${classNamePrefix}__option__content`}>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div
|
||||
className={`${classNamePrefix}__option__additional`}
|
||||
title={item.extra}
|
||||
>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, forwardRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { fetchAccount } from 'flavours/glitch/actions/accounts';
|
||||
|
@ -25,6 +27,11 @@ export const HoverCardAccount = forwardRef<
|
|||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
const note = useAppSelector(
|
||||
(state) =>
|
||||
state.relationships.getIn([accountId, 'note']) as string | undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !account) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
|
@ -57,6 +64,17 @@ export const HoverCardAccount = forwardRef<
|
|||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>
|
||||
</dt>
|
||||
<dd>{note}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hover-card__number'>
|
||||
|
|
|
@ -431,6 +431,42 @@ Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => {
|
|||
target.src = target.dataset.static;
|
||||
});
|
||||
|
||||
const setInputDisabled = (
|
||||
input: HTMLInputElement | HTMLSelectElement,
|
||||
disabled: boolean,
|
||||
) => {
|
||||
input.disabled = disabled;
|
||||
|
||||
const wrapper = input.closest('.with_label');
|
||||
if (wrapper) {
|
||||
wrapper.classList.toggle('disabled', input.disabled);
|
||||
|
||||
const hidden =
|
||||
input.type === 'checkbox' &&
|
||||
wrapper.querySelector<HTMLInputElement>('input[type=hidden][value="0"]');
|
||||
if (hidden) {
|
||||
hidden.disabled = input.disabled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#account_statuses_cleanup_policy_enabled',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement) || !target.form) return;
|
||||
|
||||
target.form
|
||||
.querySelectorAll<
|
||||
HTMLInputElement | HTMLSelectElement
|
||||
>('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select')
|
||||
.forEach((input) => {
|
||||
setInputDisabled(input, !target.checked);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
|
|
|
@ -151,7 +151,7 @@ class AccountNote extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
|
|
|
@ -3,10 +3,9 @@ import { useCallback, useState, useRef } from 'react';
|
|||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
|
||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||
|
||||
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
|
@ -53,7 +52,7 @@ export const DropdownIconButton = ({ value, disabled, icon, onChange, iconCompon
|
|||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<PrivacyDropdownMenu
|
||||
<DropdownSelector
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
|
|
|
@ -11,10 +11,9 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||
|
@ -143,7 +142,7 @@ class PrivacyDropdown extends PureComponent {
|
|||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<PrivacyDropdownMenu
|
||||
<DropdownSelector
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
|
||||
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
||||
const nodeRef = useRef(null);
|
||||
const focusedItemRef = useRef(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback((e) => {
|
||||
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, onClose]);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
onChange(value);
|
||||
}, [onClose, onChange]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex(item => (item.value === value));
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
} else {
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
setCurrentValue(element.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
||||
{items.map(item => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
PrivacyDropdownMenu.propTypes = {
|
||||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
|
@ -3,15 +3,21 @@ import { useCallback } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
|
||||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
|
||||
|
||||
|
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
|
|||
const messages = defineMessages({
|
||||
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
|
||||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
||||
view: { id: 'notification_requests.view', defaultMessage: 'View notifications' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
||||
export const NotificationRequest = ({ id, accountId, notificationsCount, checked, showCheckbox, toggleCheck }) => {
|
||||
const dispatch = useDispatch();
|
||||
const account = useSelector(state => getAccount(state, accountId));
|
||||
const intl = useIntl();
|
||||
const { push: historyPush } = useHistory();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissNotificationRequest(id));
|
||||
|
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
dispatch(acceptNotificationRequest(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
dispatch(initMuteModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
dispatch(initBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleView = useCallback(() => {
|
||||
historyPush(`/notifications/requests/${id}`);
|
||||
}, [historyPush, id]);
|
||||
|
||||
const menu = [
|
||||
{ text: intl.formatMessage(messages.view), action: handleView },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.accept), action: handleAccept },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.mute, { name: account.username }), action: handleMute, dangerous: true },
|
||||
{ text: intl.formatMessage(messages.block, { name: account.username }), action: handleBlock, dangerous: true },
|
||||
{ text: intl.formatMessage(messages.report, { name: account.username }), action: handleReport, dangerous: true },
|
||||
];
|
||||
|
||||
const handleCheck = useCallback(() => {
|
||||
toggleCheck(id);
|
||||
}, [toggleCheck, id]);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
if (showCheckbox) {
|
||||
toggleCheck(id);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [toggleCheck, id, showCheckbox]);
|
||||
|
||||
return (
|
||||
<div className='notification-request'>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
|
||||
<div className={classNames('notification-request', showCheckbox && 'notification-request--forced-checkbox')} onClick={handleClick}>
|
||||
<div className='notification-request__checkbox' aria-hidden={!showCheckbox}>
|
||||
<CheckBox checked={checked} onChange={handleCheck} />
|
||||
</div>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link' onClick={handleClick} title={account?.acct}>
|
||||
<Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} />
|
||||
|
||||
<div className='notification-request__name'>
|
||||
|
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
notificationsCount: PropTypes.string.isRequired,
|
||||
checked: PropTypes.bool,
|
||||
showCheckbox: PropTypes.bool,
|
||||
toggleCheck: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -1,13 +1,52 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||
import type { AppDispatch } from 'flavours/glitch/store';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { CheckboxWithLabel } from './checkbox_with_label';
|
||||
import { SelectWithLabel } from './select_with_label';
|
||||
|
||||
const messages = defineMessages({
|
||||
accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' },
|
||||
accept_hint: {
|
||||
id: 'notifications.policy.accept_hint',
|
||||
defaultMessage: 'Show in notifications',
|
||||
},
|
||||
filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' },
|
||||
filter_hint: {
|
||||
id: 'notifications.policy.filter_hint',
|
||||
defaultMessage: 'Send to filtered notifications inbox',
|
||||
},
|
||||
drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' },
|
||||
drop_hint: {
|
||||
id: 'notifications.policy.drop_hint',
|
||||
defaultMessage: 'Send to the void, never to be seen again',
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: change the following when we change the API
|
||||
const changeFilter = (
|
||||
dispatch: AppDispatch,
|
||||
filterType: string,
|
||||
value: string,
|
||||
) => {
|
||||
if (value === 'drop') {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'IGNORE_NOTIFICATIONS',
|
||||
modalProps: { filterType },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
export const PolicyControls: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const notificationPolicy = useAppSelector(
|
||||
|
@ -15,56 +54,74 @@ export const PolicyControls: React.FC = () => {
|
|||
);
|
||||
|
||||
const handleFilterNotFollowing = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_following: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_following', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNotFollowers = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_followers: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_followers', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNewAccounts = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_new_accounts: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_new_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterPrivateMentions = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_private_mentions: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_private_mentions', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterLimitedAccounts = useCallback(
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_limited_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!notificationPolicy) return null;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'accept',
|
||||
text: intl.formatMessage(messages.accept),
|
||||
meta: intl.formatMessage(messages.accept_hint),
|
||||
},
|
||||
{
|
||||
value: 'filter',
|
||||
text: intl.formatMessage(messages.filter),
|
||||
meta: intl.formatMessage(messages.filter_hint),
|
||||
},
|
||||
{
|
||||
value: 'drop',
|
||||
text: intl.formatMessage(messages.drop),
|
||||
meta: intl.formatMessage(messages.drop_hint),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.title'
|
||||
defaultMessage='Filter out notifications from…'
|
||||
defaultMessage='Manage notifications from…'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_following}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_following}
|
||||
onChange={handleFilterNotFollowing}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -78,11 +135,12 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Until you manually approve them'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_followers}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_followers}
|
||||
onChange={handleFilterNotFollowers}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -97,11 +155,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 3 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_new_accounts}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_new_accounts}
|
||||
onChange={handleFilterNewAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -116,11 +175,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 30 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_private_mentions}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_private_mentions}
|
||||
onChange={handleFilterPrivateMentions}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -134,7 +194,26 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_limited_accounts}
|
||||
onChange={handleFilterLimitedAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_title'
|
||||
defaultMessage='Moderated accounts'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_hint'
|
||||
defaultMessage='Limited by server moderators'
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Placement, State as PopperState } from '@popperjs/core';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
|
||||
import type { SelectItem } from 'flavours/glitch/components/dropdown_selector';
|
||||
import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
options: SelectItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
placement: initialPlacement = 'bottom-end',
|
||||
}) => {
|
||||
const activeElementRef = useRef<Element | null>(null);
|
||||
const containerRef = useRef(null);
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
) {
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
)
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
setOpen(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOverlayEnter = useCallback(
|
||||
(state: Partial<PopperState>) => {
|
||||
if (state.placement) setPlacement(state.placement);
|
||||
},
|
||||
[setPlacement],
|
||||
);
|
||||
|
||||
const valueOption = options.find((item) => item.value === value);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
className={classNames('dropdown-button', { active: isOpen })}
|
||||
>
|
||||
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
show={isOpen}
|
||||
offset={[5, 5]}
|
||||
placement={placement}
|
||||
flip
|
||||
target={containerRef}
|
||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div
|
||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||
>
|
||||
<DropdownSelector
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
onChange={onChange}
|
||||
classNamePrefix='privacy-dropdown'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: SelectItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
children,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>{children}</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
|
@ -90,6 +90,23 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
|
||||
const columnTitle = intl.formatMessage(messages.title, { name: account?.get('display_name') || account?.get('username') });
|
||||
|
||||
let explainer = null;
|
||||
|
||||
if (account?.limited) {
|
||||
const isLocal = account.acct.indexOf('@') === -1;
|
||||
explainer = (
|
||||
<div className='dismissable-banner'>
|
||||
<div className='dismissable-banner__message'>
|
||||
{isLocal ? (
|
||||
<FormattedMessage id='notification_requests.explainer_for_limited_account' defaultMessage='Notifications from this account have been filtered because the account has been limited by a moderator.' />
|
||||
) : (
|
||||
<FormattedMessage id='notification_requests.explainer_for_limited_remote_account' defaultMessage='Notifications from this account have been filtered because the account or its server has been limited by a moderator.' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={columnRef} label={columnTitle}>
|
||||
<ColumnHeader
|
||||
|
@ -109,6 +126,7 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
|
||||
<SensitiveMediaContextProvider hideMediaByDefault>
|
||||
<ScrollableList
|
||||
prepend={explainer}
|
||||
scrollKey={`notification_requests/${id}`}
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
|
|||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||
import { fetchNotificationRequests, expandNotificationRequests } from 'flavours/glitch/actions/notifications';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'flavours/glitch/actions/notifications';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
|
|||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
|
||||
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' }
|
||||
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
acceptAll: { id: 'notification_requests.accept_all', defaultMessage: 'Accept all' },
|
||||
dismissAll: { id: 'notification_requests.dismiss_all', defaultMessage: 'Dismiss all' },
|
||||
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request} other {Accept # requests}}' },
|
||||
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request} other {Dismiss # requests}}' },
|
||||
confirmAcceptAllTitle: { id: 'notification_requests.confirm_accept_all.title', defaultMessage: 'Accept notification requests?' },
|
||||
confirmAcceptAllMessage: { id: 'notification_requests.confirm_accept_all.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
|
||||
confirmAcceptAllButton: { id: 'notification_requests.confirm_accept_all.button', defaultMessage: 'Accept all' },
|
||||
confirmDismissAllTitle: { id: 'notification_requests.confirm_dismiss_all.title', defaultMessage: 'Dismiss notification requests?' },
|
||||
confirmDismissAllMessage: { id: 'notification_requests.confirm_dismiss_all.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
|
||||
confirmDismissAllButton: { id: 'notification_requests.confirm_dismiss_all.button', defaultMessage: 'Dismiss all' },
|
||||
});
|
||||
|
||||
const ColumnSettings = () => {
|
||||
|
@ -44,7 +59,7 @@ const ColumnSettings = () => {
|
|||
settingPath={['minimizeFilteredBanner']}
|
||||
onChange={onChange}
|
||||
label={
|
||||
<FormattedMessage id='notification_requests.minimize_banner' defaultMessage='Minimize filtred notifications banner' />
|
||||
<FormattedMessage id='notification_requests.minimize_banner' defaultMessage='Minimize filtered notifications banner' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
@ -55,6 +70,124 @@ const ColumnSettings = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
|
||||
const selectedCount = selectedItems.length;
|
||||
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleDismissAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleAcceptMultiple = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(selectedItems)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, selectedItems]);
|
||||
|
||||
const handleDismissMultiple = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(selectedItems)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, selectedItems]);
|
||||
|
||||
const handleToggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode((mode) => !mode);
|
||||
}, [setSelectionMode]);
|
||||
|
||||
const menu = selectedCount === 0 ?
|
||||
[
|
||||
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
|
||||
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
|
||||
] : [
|
||||
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptMultiple },
|
||||
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='column-header__select-row'>
|
||||
{selectionMode && (
|
||||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
|
||||
</div>
|
||||
)}
|
||||
<div className='column-header__select-row__selection-mode'>
|
||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||
{selectionMode ? (
|
||||
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
|
||||
) :
|
||||
(
|
||||
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{selectedCount > 0 &&
|
||||
<div className='column-header__select-row__selected-count'>
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
}
|
||||
<div className='column-header__select-row__actions'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SelectRow.propTypes = {
|
||||
selectAllChecked: PropTypes.func.isRequired,
|
||||
toggleSelectAll: PropTypes.func.isRequired,
|
||||
selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
selectionMode: PropTypes.bool,
|
||||
setSelectionMode: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const NotificationRequests = ({ multiColumn }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
@ -63,10 +196,40 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
|
||||
const [selectAllChecked, setSelectAllChecked] = useState(false);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, [columnRef]);
|
||||
|
||||
const handleCheck = useCallback(id => {
|
||||
setCheckedRequestIds(ids => {
|
||||
const position = ids.indexOf(id);
|
||||
|
||||
if(position > -1)
|
||||
ids.splice(position, 1);
|
||||
else
|
||||
ids.push(id);
|
||||
|
||||
setSelectAllChecked(ids.length === notificationRequests.size);
|
||||
|
||||
return [...ids];
|
||||
});
|
||||
}, [setCheckedRequestIds, notificationRequests]);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectAllChecked(checked => {
|
||||
if(checked)
|
||||
setCheckedRequestIds([]);
|
||||
else
|
||||
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
|
||||
|
||||
return !checked;
|
||||
});
|
||||
}, [notificationRequests]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandNotificationRequests());
|
||||
}, [dispatch]);
|
||||
|
@ -84,6 +247,8 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
appendContent={
|
||||
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
|
||||
>
|
||||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
@ -104,6 +269,9 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
id={request.get('id')}
|
||||
accountId={request.get('account')}
|
||||
notificationsCount={request.get('notifications_count')}
|
||||
showCheckbox={selectionMode}
|
||||
checked={checkedRequestIds.includes(request.get('id'))}
|
||||
toggleCheck={handleCheck}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import type { StatusVisibility } from 'flavours/glitch/api_types/statuses';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import type { NotificationGroupMention } from 'flavours/glitch/models/notification_group';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
const mentionLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.mention' defaultMessage='Mention' />
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.mention'
|
||||
defaultMessage='{name} mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_mention'
|
||||
defaultMessage='Private mention'
|
||||
/>
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = (values) => (
|
||||
const replyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
|
||||
);
|
||||
|
||||
const privateReplyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.private_mention'
|
||||
defaultMessage='{name} privately mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_reply'
|
||||
defaultMessage='Private reply'
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
|
|||
notification: NotificationGroupMention;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const statusVisibility = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn([
|
||||
notification.statusId,
|
||||
'visibility',
|
||||
]) as StatusVisibility,
|
||||
);
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = state.statuses.get(notification.statusId) as Status;
|
||||
|
||||
return [
|
||||
status.get('visibility') === 'direct',
|
||||
status.get('in_reply_to_account_id') === me,
|
||||
] as const;
|
||||
});
|
||||
|
||||
let labelRenderer = mentionLabelRenderer;
|
||||
|
||||
if (isReply && isDirect) labelRenderer = privateReplyLabelRenderer;
|
||||
else if (isReply) labelRenderer = replyLabelRenderer;
|
||||
else if (isDirect) labelRenderer = privateMentionLabelRenderer;
|
||||
|
||||
return (
|
||||
<NotificationWithStatus
|
||||
type='mention'
|
||||
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
|
||||
icon={isReply ? ReplyIcon : AlternateEmailIcon}
|
||||
iconId='reply'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
statusVisibility === 'direct'
|
||||
? privateMentionLabelRenderer
|
||||
: labelRenderer
|
||||
}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -4,8 +4,6 @@ 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';
|
||||
|
@ -26,16 +24,14 @@ import type { NotificationGap } from 'flavours/glitch/reducers/notification_grou
|
|||
import {
|
||||
selectUnreadNotificationGroupsCount,
|
||||
selectPendingNotificationGroupsCount,
|
||||
selectAnyPendingNotification,
|
||||
selectNotificationGroups,
|
||||
} from 'flavours/glitch/selectors/notifications';
|
||||
import {
|
||||
selectNeedsNotificationPermission,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsShowUnread,
|
||||
} from 'flavours/glitch/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { submitMarkers } from '../../actions/markers';
|
||||
|
@ -61,41 +57,19 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
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 notifications = useAppSelector(selectNotificationGroups);
|
||||
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
|
||||
? s.notificationGroups.readMarkerId
|
||||
: '0',
|
||||
);
|
||||
|
||||
|
@ -105,11 +79,13 @@ export const Notifications: React.FC<{
|
|||
selectUnreadNotificationGroupsCount,
|
||||
);
|
||||
|
||||
const anyPendingNotification = useAppSelector(selectAnyPendingNotification);
|
||||
|
||||
const isUnread = unreadNotificationsCount > 0;
|
||||
|
||||
const canMarkAsRead =
|
||||
useAppSelector(selectSettingsNotificationsShowUnread) &&
|
||||
unreadNotificationsCount > 0;
|
||||
anyPendingNotification;
|
||||
|
||||
const needsNotificationPermission = useAppSelector(
|
||||
selectNeedsNotificationPermission,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
@ -19,6 +19,7 @@ import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
|||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
|
@ -637,7 +638,7 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
let ancestors, descendants, remoteHint;
|
||||
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
|
@ -668,6 +669,10 @@ class Status extends ImmutablePureComponent {
|
|||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
if (!isLocal) {
|
||||
remoteHint = <TimelineHint url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -742,6 +747,7 @@ class Status extends ImmutablePureComponent {
|
|||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||
import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react';
|
||||
import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react';
|
||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
export const IgnoreNotificationsModal = ({ filterType }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' }));
|
||||
}, [dispatch, filterType]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' }));
|
||||
}, [dispatch, filterType]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
let title = null;
|
||||
|
||||
switch(filterType) {
|
||||
case 'for_not_following':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.not_following_title' defaultMessage="Ignore notifications from people you don't follow?" />;
|
||||
break;
|
||||
case 'for_not_followers':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.not_followers_title' defaultMessage='Ignore notifications from people not following you?' />;
|
||||
break;
|
||||
case 'for_new_accounts':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.new_accounts_title' defaultMessage='Ignore notifications from new accounts?' />;
|
||||
break;
|
||||
case 'for_private_mentions':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.private_mentions_title' defaultMessage='Ignore notifications from unsolicited Private Mentions?' />;
|
||||
break;
|
||||
case 'for_limited_accounts':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={InventoryIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_review_separately' defaultMessage='You can review filtered notifications speparately' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonAlertIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_act_users' defaultMessage="You'll still be able to accept, reject, or report users" /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ShieldQuestionIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_avoid_confusion' defaultMessage='Filtering helps avoid potential confusion' /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormattedMessage id='ignore_notifications_modal.disclaimer' defaultMessage="Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage id='ignore_notifications_modal.filter_instead' defaultMessage='Filter instead' />
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<button onClick={handleClick} className='link-button'>
|
||||
<FormattedMessage id='ignore_notifications_modal.ignore' defaultMessage='Ignore notifications' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
IgnoreNotificationsModal.propTypes = {
|
||||
filterType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default IgnoreNotificationsModal;
|
|
@ -19,6 +19,7 @@ import {
|
|||
InteractionModal,
|
||||
SubscribedLanguagesModal,
|
||||
ClosedRegistrationsModal,
|
||||
IgnoreNotificationsModal,
|
||||
} from 'flavours/glitch/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||
|
||||
|
@ -80,6 +81,7 @@ export const MODAL_COMPONENTS = {
|
|||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
'INTERACTION': InteractionModal,
|
||||
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
|
|
@ -146,6 +146,10 @@ export function SettingsModal () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'../../local_settings');
|
||||
}
|
||||
|
||||
export function IgnoreNotificationsModal () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/ignore_notifications_modal" */'../components/ignore_notifications_modal');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ interface NotificationGroupsState {
|
|||
scrolledToTop: boolean;
|
||||
isLoading: boolean;
|
||||
lastReadId: string;
|
||||
readMarkerId: string;
|
||||
mounted: number;
|
||||
isTabVisible: boolean;
|
||||
}
|
||||
|
@ -58,7 +59,8 @@ const initialState: NotificationGroupsState = {
|
|||
scrolledToTop: false,
|
||||
isLoading: false,
|
||||
// The following properties are used to track unread notifications
|
||||
lastReadId: '0', // used for unread notifications
|
||||
lastReadId: '0', // used internally for unread notifications
|
||||
readMarkerId: '0', // user-facing and updated when focus changes
|
||||
mounted: 0, // number of mounted notification list components, usually 0 or 1
|
||||
isTabVisible: true,
|
||||
};
|
||||
|
@ -284,6 +286,12 @@ function updateLastReadId(
|
|||
}
|
||||
}
|
||||
|
||||
function commitLastReadId(state: NotificationGroupsState) {
|
||||
if (shouldMarkNewNotificationsAsRead(state)) {
|
||||
state.readMarkerId = state.lastReadId;
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
initialState,
|
||||
(builder) => {
|
||||
|
@ -457,6 +465,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
|||
compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0
|
||||
)
|
||||
state.lastReadId = mostRecentGroup.page_max_id;
|
||||
commitLastReadId(state);
|
||||
})
|
||||
.addCase(fetchMarkers.fulfilled, (state, action) => {
|
||||
if (
|
||||
|
@ -465,11 +474,15 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
|||
state.lastReadId,
|
||||
action.payload.markers.notifications.last_read_id,
|
||||
) < 0
|
||||
)
|
||||
) {
|
||||
state.lastReadId = action.payload.markers.notifications.last_read_id;
|
||||
state.readMarkerId =
|
||||
action.payload.markers.notifications.last_read_id;
|
||||
}
|
||||
})
|
||||
.addCase(mountNotifications, (state) => {
|
||||
state.mounted += 1;
|
||||
commitLastReadId(state);
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(unmountNotifications, (state) => {
|
||||
|
@ -477,6 +490,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
|||
})
|
||||
.addCase(focusApp, (state) => {
|
||||
state.isTabVisible = true;
|
||||
commitLastReadId(state);
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(unfocusApp, (state) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import { blockAccountSuccess, muteAccountSuccess } from 'flavours/glitch/actions/accounts';
|
||||
import {
|
||||
NOTIFICATION_REQUESTS_EXPAND_REQUEST,
|
||||
NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
|
||||
|
@ -12,6 +13,8 @@ import {
|
|||
NOTIFICATION_REQUEST_FETCH_FAIL,
|
||||
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
||||
NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
||||
NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
||||
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
||||
|
@ -51,6 +54,14 @@ const removeRequest = (state, id) => {
|
|||
return state.update('items', list => list.filterNot(item => item.get('id') === id));
|
||||
};
|
||||
|
||||
const removeRequestByAccount = (state, account_id) => {
|
||||
if (state.getIn(['current', 'item', 'account']) === account_id) {
|
||||
state = state.setIn(['current', 'removed'], true);
|
||||
}
|
||||
|
||||
return state.update('items', list => list.filterNot(item => item.get('account') === account_id));
|
||||
};
|
||||
|
||||
export const notificationRequestsReducer = (state = initialState, action) => {
|
||||
switch(action.type) {
|
||||
case NOTIFICATION_REQUESTS_FETCH_SUCCESS:
|
||||
|
@ -74,6 +85,13 @@ export const notificationRequestsReducer = (state = initialState, action) => {
|
|||
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
|
||||
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
|
||||
return removeRequest(state, action.id);
|
||||
case NOTIFICATION_REQUESTS_ACCEPT_REQUEST:
|
||||
case NOTIFICATION_REQUESTS_DISMISS_REQUEST:
|
||||
return action.ids.reduce((state, id) => removeRequest(state, id), state);
|
||||
case blockAccountSuccess.type:
|
||||
return removeRequestByAccount(state, action.payload.relationship.id);
|
||||
case muteAccountSuccess.type:
|
||||
return action.payload.relationship.muting_notifications ? removeRequestByAccount(state, action.payload.relationship.id) : state;
|
||||
case NOTIFICATION_REQUEST_FETCH_REQUEST:
|
||||
return state.set('current', initialState.get('current').set('isLoading', true));
|
||||
case NOTIFICATION_REQUEST_FETCH_SUCCESS:
|
||||
|
|
|
@ -1,15 +1,62 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { compareId } from 'flavours/glitch/compare_id';
|
||||
import type { NotificationGroup } from 'flavours/glitch/models/notification_group';
|
||||
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
|
||||
import {
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
} from './settings';
|
||||
|
||||
const filterNotificationsByAllowedTypes = (
|
||||
showFilterBar: boolean,
|
||||
allowedType: string,
|
||||
excludedTypes: string[],
|
||||
notifications: (NotificationGroup | NotificationGap)[],
|
||||
) => {
|
||||
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 selectNotificationGroups = createSelector(
|
||||
[
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
(state: RootState) => state.notificationGroups.groups,
|
||||
],
|
||||
filterNotificationsByAllowedTypes,
|
||||
);
|
||||
|
||||
const selectPendingNotificationGroups = createSelector(
|
||||
[
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
(state: RootState) => state.notificationGroups.pendingGroups,
|
||||
],
|
||||
filterNotificationsByAllowedTypes,
|
||||
);
|
||||
|
||||
export const selectUnreadNotificationGroupsCount = createSelector(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.lastReadId,
|
||||
(s: RootState) => s.notificationGroups.pendingGroups,
|
||||
(s: RootState) => s.notificationGroups.groups,
|
||||
selectNotificationGroups,
|
||||
selectPendingNotificationGroups,
|
||||
],
|
||||
(notificationMarker, pendingGroups, groups) => {
|
||||
(notificationMarker, groups, pendingGroups) => {
|
||||
return (
|
||||
groups.filter(
|
||||
(group) =>
|
||||
|
@ -27,8 +74,24 @@ export const selectUnreadNotificationGroupsCount = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
// Whether there is any unread notification according to the user-facing state
|
||||
export const selectAnyPendingNotification = createSelector(
|
||||
[
|
||||
(s: RootState) => s.notificationGroups.readMarkerId,
|
||||
selectNotificationGroups,
|
||||
],
|
||||
(notificationMarker, groups) => {
|
||||
return groups.some(
|
||||
(group) =>
|
||||
group.type !== 'gap' &&
|
||||
group.page_max_id &&
|
||||
compareId(group.page_max_id, notificationMarker) > 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const selectPendingNotificationGroupsCount = createSelector(
|
||||
[(s: RootState) => s.notificationGroups.pendingGroups],
|
||||
[selectPendingNotificationGroups],
|
||||
(pendingGroups) =>
|
||||
pendingGroups.filter((group) => group.type !== 'gap').length,
|
||||
);
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import type { GetThunkAPI } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import type { BaseThunkAPI } from '@reduxjs/toolkit/dist/createAsyncThunk';
|
||||
|
||||
import type { AppDispatch, RootState } from './store';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
|
@ -25,29 +24,20 @@ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
|||
rejectValue: AsyncThunkRejectValue;
|
||||
}>();
|
||||
|
||||
type AppThunkApi = Pick<
|
||||
BaseThunkAPI<
|
||||
RootState,
|
||||
unknown,
|
||||
AppDispatch,
|
||||
AsyncThunkRejectValue,
|
||||
AppMeta,
|
||||
AppMeta
|
||||
>,
|
||||
'getState' | 'dispatch'
|
||||
>;
|
||||
|
||||
interface AppThunkOptions {
|
||||
skipLoading?: boolean;
|
||||
}
|
||||
|
||||
const createBaseAsyncThunk = createAsyncThunk.withTypes<{
|
||||
interface AppThunkConfig {
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
rejectValue: AsyncThunkRejectValue;
|
||||
fulfilledMeta: AppMeta;
|
||||
rejectedMeta: AppMeta;
|
||||
}>();
|
||||
}
|
||||
type AppThunkApi = Pick<GetThunkAPI<AppThunkConfig>, 'getState' | 'dispatch'>;
|
||||
|
||||
interface AppThunkOptions {
|
||||
skipLoading?: boolean;
|
||||
}
|
||||
|
||||
const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
|
||||
|
||||
export function createThunk<Arg = void, Returned = void>(
|
||||
name: string,
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
background: $ui-base-color;
|
||||
color: $darker-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
border: 1px solid var(--background-border-color);
|
||||
font-size: 17px;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
|
|
|
@ -10,6 +10,13 @@ $content-width: 840px;
|
|||
width: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: top;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.sidebar-wrapper {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -403,7 +403,7 @@ body > [data-popper-placement] {
|
|||
&__suggestions {
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
background: $ui-base-color;
|
||||
border: 1px solid lighten($ui-base-color, 14%);
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: $secondary-text-color;
|
||||
font-size: 14px;
|
||||
|
@ -926,6 +926,13 @@ body > [data-popper-placement] {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: $highlight-text-color;
|
||||
border-color: $highlight-text-color;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
|
@ -2974,6 +2981,11 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
&.privacy-policy {
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4094,18 +4106,17 @@ input.glitch-setting-text {
|
|||
display: block;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
color: $inverted-text-color;
|
||||
background: $white;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 7px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $white;
|
||||
border: 1px solid var(--background-border-color);
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: lighten($ui-highlight-color, 12%);
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
|
@ -4527,6 +4538,36 @@ a.status-card {
|
|||
}
|
||||
}
|
||||
|
||||
.column-header__select-row {
|
||||
border-width: 0 1px 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--background-border-color);
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&__checkbox .check-box {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__selection-mode {
|
||||
flex-grow: 1;
|
||||
|
||||
.text-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.icon-button {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
|
@ -4739,6 +4780,11 @@ a.status-card {
|
|||
.column-header__collapsible-inner {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header__setting-btn {
|
||||
|
@ -6732,9 +6778,10 @@ a.status-card {
|
|||
max-width: 90vw;
|
||||
width: 480px;
|
||||
height: 80vh;
|
||||
background: lighten($ui-secondary-color, 8%);
|
||||
color: $inverted-text-color;
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: $primary-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
@ -6742,7 +6789,7 @@ a.status-card {
|
|||
|
||||
&__container {
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid $ui-secondary-color;
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
padding: 20px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
|
@ -6772,7 +6819,7 @@ a.status-card {
|
|||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
margin-bottom: 30px;
|
||||
|
||||
a {
|
||||
|
@ -6807,7 +6854,7 @@ a.status-card {
|
|||
|
||||
.status__content,
|
||||
.status__content p {
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.status__content__spoiler-link {
|
||||
|
@ -6852,7 +6899,7 @@ a.status-card {
|
|||
.poll__option.dialog-option {
|
||||
padding: 15px 0;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid $ui-secondary-color;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
|
@ -6860,13 +6907,13 @@ a.status-card {
|
|||
|
||||
& > .poll__option__text {
|
||||
font-size: 13px;
|
||||
color: lighten($inverted-text-color, 16%);
|
||||
color: $secondary-text-color;
|
||||
|
||||
strong {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
|
@ -6885,22 +6932,19 @@ a.status-card {
|
|||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
color: $primary-text-color;
|
||||
background: $ui-base-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
border: 1px solid var(--background-border-color);
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
|
||||
&::placeholder {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
@ -6921,16 +6965,16 @@ a.status-card {
|
|||
}
|
||||
|
||||
.button.button-secondary {
|
||||
border-color: $inverted-text-color;
|
||||
color: $inverted-text-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $ui-button-destructive-background-color;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
border-color: $ui-button-background-color;
|
||||
color: $ui-button-background-color;
|
||||
background: $ui-button-destructive-background-color;
|
||||
border-color: $ui-button-destructive-background-color;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7923,7 +7967,7 @@ img.modal-warning {
|
|||
display: flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
@media screen and (max-width: $no-gap-breakpoint - 1px) {
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
@ -8020,20 +8064,9 @@ img.modal-warning {
|
|||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
border-color: $ui-highlight-color;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
content: '';
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: $ui-highlight-color;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
@ -8043,19 +8076,28 @@ img.modal-warning {
|
|||
}
|
||||
}
|
||||
|
||||
.radio-button.checked::before {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
content: '';
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: $ui-highlight-color;
|
||||
}
|
||||
|
||||
.check-box {
|
||||
&__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
|
||||
&.checked {
|
||||
&.checked,
|
||||
&.indeterminate {
|
||||
background: $ui-highlight-color;
|
||||
color: $white;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8224,6 +8266,11 @@ noscript {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer__backdrop {
|
||||
|
@ -8644,16 +8691,17 @@ noscript {
|
|||
.verified {
|
||||
border: 1px solid rgba($valid-value-color, 0.5);
|
||||
margin-top: -1px;
|
||||
margin-inline: -1px;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
dt,
|
||||
|
@ -9359,10 +9407,13 @@ noscript {
|
|||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
||||
@media screen and (min-width: $no-gap-breakpoint) {
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.story {
|
||||
|
@ -10776,12 +10827,28 @@ noscript {
|
|||
}
|
||||
|
||||
.notification-request {
|
||||
$padding: 15px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 15px;
|
||||
padding: $padding;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
|
||||
&__checkbox {
|
||||
position: absolute;
|
||||
inset-inline-start: $padding;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
|
||||
.check-box {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -10839,6 +10906,31 @@ noscript {
|
|||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-request__link {
|
||||
transition: padding-inline-start 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
&--forced-checkbox {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: lighten($ui-base-color, 1%);
|
||||
}
|
||||
|
||||
.notification-request__checkbox {
|
||||
opacity: 1;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.notification-request__link {
|
||||
padding-inline-start: 30px;
|
||||
}
|
||||
|
||||
.notification-request__actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-from-author {
|
||||
|
@ -11195,6 +11287,25 @@ noscript {
|
|||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
&-label {
|
||||
color: $dark-text-color;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
white-space: pre-line;
|
||||
color: $secondary-text-color;
|
||||
overflow: hidden;
|
||||
line-clamp: 3; // Not yet supported in browers
|
||||
display: -webkit-box; // The next 3 properties are needed
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
|
|
|
@ -83,11 +83,6 @@
|
|||
max-height: 35vh;
|
||||
padding: 0 6px 6px;
|
||||
will-change: transform;
|
||||
|
||||
&::-webkit-scrollbar-track:hover,
|
||||
&::-webkit-scrollbar-track:active {
|
||||
background-color: rgba($base-overlay-background, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-search {
|
||||
|
@ -116,7 +111,6 @@
|
|||
&:focus {
|
||||
outline: none !important;
|
||||
border-width: 1px !important;
|
||||
border-color: $ui-button-background-color;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
|
|
|
@ -442,11 +442,6 @@ code {
|
|||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
|
||||
&::placeholder {
|
||||
color: $dark-text-color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
@ -608,8 +603,7 @@ code {
|
|||
inset-inline-end: 3px;
|
||||
top: 1px;
|
||||
padding: 10px;
|
||||
padding-bottom: 9px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: $dark-text-color;
|
||||
font-family: inherit;
|
||||
pointer-events: none;
|
||||
|
@ -626,11 +620,6 @@ code {
|
|||
inset-inline-end: 0;
|
||||
bottom: 1px;
|
||||
width: 5px;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
rgba(darken($ui-base-color, 10%), 0),
|
||||
darken($ui-base-color, 10%)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -214,12 +214,6 @@ html {
|
|||
border-top-color: lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
.column-header__collapsible-inner {
|
||||
background: darken($ui-base-color, 4%);
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.column-settings__hashtags .column-select__option {
|
||||
color: $white;
|
||||
}
|
||||
|
@ -559,11 +553,11 @@ html {
|
|||
|
||||
.compose-form .autosuggest-textarea__textarea,
|
||||
.compose-form__highlightable,
|
||||
.autosuggest-textarea__suggestions,
|
||||
.search__input,
|
||||
.search__popout,
|
||||
.emoji-mart-search input,
|
||||
.language-dropdown__dropdown .emoji-mart-search input,
|
||||
// .strike-card,
|
||||
.poll__option input[type='text'] {
|
||||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
|
@ -620,3 +614,11 @@ a.sparkline {
|
|||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-text {
|
||||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
|
||||
.report-dialog-modal__textarea {
|
||||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ $valid-value-color: $success-green !default;
|
|||
|
||||
$ui-base-color: $classic-secondary-color !default;
|
||||
$ui-base-lighter-color: #b0c0cf;
|
||||
$ui-primary-color: #9bcbed;
|
||||
$ui-primary-color: $classic-primary-color !default;
|
||||
$ui-secondary-color: $classic-base-color !default;
|
||||
$ui-highlight-color: $classic-highlight-color !default;
|
||||
|
||||
|
|
|
@ -56,40 +56,3 @@ table {
|
|||
html {
|
||||
scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
border: 0px none $base-border-color;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: lighten($ui-base-color, 6%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border: 0px none $base-border-color;
|
||||
border-radius: 0;
|
||||
background: rgba($base-overlay-background, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background: $ui-base-color;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:active {
|
||||
background: $ui-base-color;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
@ -90,16 +90,6 @@ body.rtl {
|
|||
direction: rtl;
|
||||
}
|
||||
|
||||
.simple_form .label_input__append {
|
||||
&::after {
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
rgba(darken($ui-base-color, 10%), 0),
|
||||
darken($ui-base-color, 10%)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.simple_form select {
|
||||
background: $ui-base-color
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>")
|
||||
|
|
|
@ -273,8 +273,8 @@ a.table-action-link {
|
|||
}
|
||||
}
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--background-color);
|
||||
&:last-child {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
|
|
|
@ -211,7 +211,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
|
|
|
@ -64,6 +64,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS
|
|||
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
|
||||
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
||||
|
@ -496,6 +504,62 @@ export const dismissNotificationRequestFail = (id, error) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequests = (ids) => (dispatch, getState) => {
|
||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
||||
dispatch(acceptNotificationRequestsRequest(ids));
|
||||
|
||||
api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => {
|
||||
dispatch(acceptNotificationRequestsSuccess(ids));
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
}).catch(err => {
|
||||
dispatch(acceptNotificationRequestFail(ids, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptNotificationRequestsRequest = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequestsSuccess = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const acceptNotificationRequestsFail = (ids, error) => ({
|
||||
type: NOTIFICATION_REQUESTS_ACCEPT_FAIL,
|
||||
ids,
|
||||
error,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequests = (ids) => (dispatch, getState) => {
|
||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
||||
dispatch(acceptNotificationRequestsRequest(ids));
|
||||
|
||||
api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => {
|
||||
dispatch(dismissNotificationRequestsSuccess(ids));
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
}).catch(err => {
|
||||
dispatch(dismissNotificationRequestFail(ids, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissNotificationRequestsRequest = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequestsSuccess = ids => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS,
|
||||
ids,
|
||||
});
|
||||
|
||||
export const dismissNotificationRequestsFail = (ids, error) => ({
|
||||
type: NOTIFICATION_REQUESTS_DISMISS_FAIL,
|
||||
ids,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
|
||||
const current = getState().getIn(['notificationRequests', 'current']);
|
||||
const params = { account_id: accountId };
|
||||
|
|
|
@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api';
|
|||
import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies';
|
||||
|
||||
export const apiGetNotificationPolicy = () =>
|
||||
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
|
||||
apiRequestGet<NotificationPolicyJSON>('/v2/notifications/policy');
|
||||
|
||||
export const apiUpdateNotificationsPolicy = (
|
||||
policy: Partial<NotificationPolicyJSON>,
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
|
||||
) => apiRequestPut<NotificationPolicyJSON>('/v2/notifications/policy', policy);
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
// See app/serializers/rest/notification_policy_serializer.rb
|
||||
|
||||
export type NotificationPolicyValue = 'accept' | 'filter' | 'drop';
|
||||
|
||||
export interface NotificationPolicyJSON {
|
||||
filter_not_following: boolean;
|
||||
filter_not_followers: boolean;
|
||||
filter_new_accounts: boolean;
|
||||
filter_private_mentions: boolean;
|
||||
for_not_following: NotificationPolicyValue;
|
||||
for_not_followers: NotificationPolicyValue;
|
||||
for_new_accounts: NotificationPolicyValue;
|
||||
for_private_mentions: NotificationPolicyValue;
|
||||
for_limited_accounts: NotificationPolicyValue;
|
||||
summary: {
|
||||
pending_requests_count: number;
|
||||
pending_notifications_count: number;
|
||||
|
|
|
@ -106,7 +106,7 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react';
|
||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
@ -7,6 +8,7 @@ import { Icon } from './icon';
|
|||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
|
@ -16,6 +18,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
name,
|
||||
value,
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
|
@ -29,8 +32,14 @@ export const CheckBox: React.FC<Props> = ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('check-box__input', { checked })}>
|
||||
{checked && <Icon id='check' icon={DoneIcon} />}
|
||||
<span
|
||||
className={classNames('check-box__input', { checked, indeterminate })}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
|
||||
) : (
|
||||
checked && <Icon id='check' icon={DoneIcon} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
|
|
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents
|
||||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
meta: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
classNamePrefix: string;
|
||||
style?: React.CSSProperties;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DropdownSelector: React.FC<Props> = ({
|
||||
style,
|
||||
items,
|
||||
value,
|
||||
classNamePrefix = 'privacy-dropdown',
|
||||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const nodeRef = useRef<HTMLUListElement>(null);
|
||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
nodeRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, onClose],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||
) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
if (value) onChange(value);
|
||||
},
|
||||
[onClose, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex((item) => item.value === value);
|
||||
|
||||
let element: Element | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
const selectedValue = element.getAttribute('data-index');
|
||||
element.focus();
|
||||
if (selectedValue) setCurrentValue(selectedValue);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener(
|
||||
'touchend',
|
||||
handleDocumentClick,
|
||||
listenerOptions,
|
||||
);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={style} role='listbox' ref={nodeRef}>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames(`${classNamePrefix}__option`, {
|
||||
active: item.value === currentValue,
|
||||
})}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
{item.icon && item.iconComponent && (
|
||||
<div className={`${classNamePrefix}__option__icon`}>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${classNamePrefix}__option__content`}>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div
|
||||
className={`${classNamePrefix}__option__additional`}
|
||||
title={item.extra}
|
||||
>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, forwardRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
@ -25,6 +27,11 @@ export const HoverCardAccount = forwardRef<
|
|||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
const note = useAppSelector(
|
||||
(state) =>
|
||||
state.relationships.getIn([accountId, 'note']) as string | undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !account) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
|
@ -53,6 +60,17 @@ export const HoverCardAccount = forwardRef<
|
|||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>
|
||||
</dt>
|
||||
<dd>{note}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hover-card__number'>
|
||||
|
|
|
@ -151,7 +151,7 @@ class AccountNote extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
|
|
|
@ -11,10 +11,9 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||
|
@ -143,7 +142,7 @@ class PrivacyDropdown extends PureComponent {
|
|||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<PrivacyDropdownMenu
|
||||
<DropdownSelector
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
|
||||
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
||||
const nodeRef = useRef(null);
|
||||
const focusedItemRef = useRef(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback((e) => {
|
||||
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, onClose]);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
onChange(value);
|
||||
}, [onClose, onChange]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex(item => (item.value === value));
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
} else {
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
setCurrentValue(element.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
||||
{items.map(item => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
PrivacyDropdownMenu.propTypes = {
|
||||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
|
@ -3,15 +3,21 @@ import { useCallback } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { initBlockModal } from 'mastodon/actions/blocks';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications';
|
||||
import { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||
|
||||
|
@ -20,12 +26,18 @@ const getAccount = makeGetAccount();
|
|||
const messages = defineMessages({
|
||||
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
|
||||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
||||
view: { id: 'notification_requests.view', defaultMessage: 'View notifications' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
||||
export const NotificationRequest = ({ id, accountId, notificationsCount, checked, showCheckbox, toggleCheck }) => {
|
||||
const dispatch = useDispatch();
|
||||
const account = useSelector(state => getAccount(state, accountId));
|
||||
const intl = useIntl();
|
||||
const { push: historyPush } = useHistory();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissNotificationRequest(id));
|
||||
|
@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
dispatch(acceptNotificationRequest(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
dispatch(initMuteModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
dispatch(initBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleView = useCallback(() => {
|
||||
historyPush(`/notifications/requests/${id}`);
|
||||
}, [historyPush, id]);
|
||||
|
||||
const menu = [
|
||||
{ text: intl.formatMessage(messages.view), action: handleView },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.accept), action: handleAccept },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.mute, { name: account.username }), action: handleMute, dangerous: true },
|
||||
{ text: intl.formatMessage(messages.block, { name: account.username }), action: handleBlock, dangerous: true },
|
||||
{ text: intl.formatMessage(messages.report, { name: account.username }), action: handleReport, dangerous: true },
|
||||
];
|
||||
|
||||
const handleCheck = useCallback(() => {
|
||||
toggleCheck(id);
|
||||
}, [toggleCheck, id]);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
if (showCheckbox) {
|
||||
toggleCheck(id);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [toggleCheck, id, showCheckbox]);
|
||||
|
||||
return (
|
||||
<div className='notification-request'>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */
|
||||
<div className={classNames('notification-request', showCheckbox && 'notification-request--forced-checkbox')} onClick={handleClick}>
|
||||
<div className='notification-request__checkbox' aria-hidden={!showCheckbox}>
|
||||
<CheckBox checked={checked} onChange={handleCheck} />
|
||||
</div>
|
||||
<Link to={`/notifications/requests/${id}`} className='notification-request__link' onClick={handleClick} title={account?.acct}>
|
||||
<Avatar account={account} size={40} counter={toCappedNumber(notificationsCount)} />
|
||||
|
||||
<div className='notification-request__name'>
|
||||
|
@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -61,4 +121,7 @@ NotificationRequest.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
notificationsCount: PropTypes.string.isRequired,
|
||||
checked: PropTypes.bool,
|
||||
showCheckbox: PropTypes.bool,
|
||||
toggleCheck: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -1,13 +1,52 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
|
||||
import type { AppDispatch } from 'mastodon/store';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CheckboxWithLabel } from './checkbox_with_label';
|
||||
import { SelectWithLabel } from './select_with_label';
|
||||
|
||||
const messages = defineMessages({
|
||||
accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' },
|
||||
accept_hint: {
|
||||
id: 'notifications.policy.accept_hint',
|
||||
defaultMessage: 'Show in notifications',
|
||||
},
|
||||
filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' },
|
||||
filter_hint: {
|
||||
id: 'notifications.policy.filter_hint',
|
||||
defaultMessage: 'Send to filtered notifications inbox',
|
||||
},
|
||||
drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' },
|
||||
drop_hint: {
|
||||
id: 'notifications.policy.drop_hint',
|
||||
defaultMessage: 'Send to the void, never to be seen again',
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: change the following when we change the API
|
||||
const changeFilter = (
|
||||
dispatch: AppDispatch,
|
||||
filterType: string,
|
||||
value: string,
|
||||
) => {
|
||||
if (value === 'drop') {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'IGNORE_NOTIFICATIONS',
|
||||
modalProps: { filterType },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
export const PolicyControls: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const notificationPolicy = useAppSelector(
|
||||
|
@ -15,56 +54,74 @@ export const PolicyControls: React.FC = () => {
|
|||
);
|
||||
|
||||
const handleFilterNotFollowing = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_following: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_following', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNotFollowers = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_not_followers: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_not_followers', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterNewAccounts = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_new_accounts: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_new_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterPrivateMentions = useCallback(
|
||||
(checked: boolean) => {
|
||||
void dispatch(
|
||||
updateNotificationsPolicy({ filter_private_mentions: checked }),
|
||||
);
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_private_mentions', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleFilterLimitedAccounts = useCallback(
|
||||
(value: string) => {
|
||||
changeFilter(dispatch, 'for_limited_accounts', value);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!notificationPolicy) return null;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'accept',
|
||||
text: intl.formatMessage(messages.accept),
|
||||
meta: intl.formatMessage(messages.accept_hint),
|
||||
},
|
||||
{
|
||||
value: 'filter',
|
||||
text: intl.formatMessage(messages.filter),
|
||||
meta: intl.formatMessage(messages.filter_hint),
|
||||
},
|
||||
{
|
||||
value: 'drop',
|
||||
text: intl.formatMessage(messages.drop),
|
||||
meta: intl.formatMessage(messages.drop_hint),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.title'
|
||||
defaultMessage='Filter out notifications from…'
|
||||
defaultMessage='Manage notifications from…'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_following}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_following}
|
||||
onChange={handleFilterNotFollowing}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -78,11 +135,12 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage='Until you manually approve them'
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_not_followers}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_followers}
|
||||
onChange={handleFilterNotFollowers}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -97,11 +155,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 3 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_new_accounts}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_new_accounts}
|
||||
onChange={handleFilterNewAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -116,11 +175,12 @@ export const PolicyControls: React.FC = () => {
|
|||
values={{ days: 30 }}
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<CheckboxWithLabel
|
||||
checked={notificationPolicy.filter_private_mentions}
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_private_mentions}
|
||||
onChange={handleFilterPrivateMentions}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
|
@ -134,7 +194,26 @@ export const PolicyControls: React.FC = () => {
|
|||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||
/>
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</SelectWithLabel>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_limited_accounts}
|
||||
onChange={handleFilterLimitedAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_title'
|
||||
defaultMessage='Moderated accounts'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_hint'
|
||||
defaultMessage='Limited by server moderators'
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Placement, State as PopperState } from '@popperjs/core';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
|
||||
import type { SelectItem } from 'mastodon/components/dropdown_selector';
|
||||
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
options: SelectItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
placement: initialPlacement = 'bottom-end',
|
||||
}) => {
|
||||
const activeElementRef = useRef<Element | null>(null);
|
||||
const containerRef = useRef(null);
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
) {
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
)
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
setOpen(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOverlayEnter = useCallback(
|
||||
(state: Partial<PopperState>) => {
|
||||
if (state.placement) setPlacement(state.placement);
|
||||
},
|
||||
[setPlacement],
|
||||
);
|
||||
|
||||
const valueOption = options.find((item) => item.value === value);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
className={classNames('dropdown-button', { active: isOpen })}
|
||||
>
|
||||
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
show={isOpen}
|
||||
offset={[5, 5]}
|
||||
placement={placement}
|
||||
flip
|
||||
target={containerRef}
|
||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div
|
||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||
>
|
||||
<DropdownSelector
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
onChange={onChange}
|
||||
classNamePrefix='privacy-dropdown'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: SelectItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
children,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>{children}</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
|
@ -90,6 +90,23 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
|
||||
const columnTitle = intl.formatMessage(messages.title, { name: account?.get('display_name') || account?.get('username') });
|
||||
|
||||
let explainer = null;
|
||||
|
||||
if (account?.limited) {
|
||||
const isLocal = account.acct.indexOf('@') === -1;
|
||||
explainer = (
|
||||
<div className='dismissable-banner'>
|
||||
<div className='dismissable-banner__message'>
|
||||
{isLocal ? (
|
||||
<FormattedMessage id='notification_requests.explainer_for_limited_account' defaultMessage='Notifications from this account have been filtered because the account has been limited by a moderator.' />
|
||||
) : (
|
||||
<FormattedMessage id='notification_requests.explainer_for_limited_remote_account' defaultMessage='Notifications from this account have been filtered because the account or its server has been limited by a moderator.' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={columnRef} label={columnTitle}>
|
||||
<ColumnHeader
|
||||
|
@ -109,6 +126,7 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
|
||||
<SensitiveMediaContextProvider hideMediaByDefault>
|
||||
<ScrollableList
|
||||
prepend={explainer}
|
||||
scrollKey={`notification_requests/${id}`}
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet';
|
|||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||
import { fetchNotificationRequests, expandNotificationRequests } from 'mastodon/actions/notifications';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'mastodon/actions/notifications';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle';
|
|||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
|
||||
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' }
|
||||
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
acceptAll: { id: 'notification_requests.accept_all', defaultMessage: 'Accept all' },
|
||||
dismissAll: { id: 'notification_requests.dismiss_all', defaultMessage: 'Dismiss all' },
|
||||
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request} other {Accept # requests}}' },
|
||||
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request} other {Dismiss # requests}}' },
|
||||
confirmAcceptAllTitle: { id: 'notification_requests.confirm_accept_all.title', defaultMessage: 'Accept notification requests?' },
|
||||
confirmAcceptAllMessage: { id: 'notification_requests.confirm_accept_all.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
|
||||
confirmAcceptAllButton: { id: 'notification_requests.confirm_accept_all.button', defaultMessage: 'Accept all' },
|
||||
confirmDismissAllTitle: { id: 'notification_requests.confirm_dismiss_all.title', defaultMessage: 'Dismiss notification requests?' },
|
||||
confirmDismissAllMessage: { id: 'notification_requests.confirm_dismiss_all.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
|
||||
confirmDismissAllButton: { id: 'notification_requests.confirm_dismiss_all.button', defaultMessage: 'Dismiss all' },
|
||||
});
|
||||
|
||||
const ColumnSettings = () => {
|
||||
|
@ -44,7 +59,7 @@ const ColumnSettings = () => {
|
|||
settingPath={['minimizeFilteredBanner']}
|
||||
onChange={onChange}
|
||||
label={
|
||||
<FormattedMessage id='notification_requests.minimize_banner' defaultMessage='Minimize filtred notifications banner' />
|
||||
<FormattedMessage id='notification_requests.minimize_banner' defaultMessage='Minimize filtered notifications banner' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
@ -55,6 +70,124 @@ const ColumnSettings = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
|
||||
const selectedCount = selectedItems.length;
|
||||
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleDismissAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleAcceptMultiple = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(selectedItems)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, selectedItems]);
|
||||
|
||||
const handleDismissMultiple = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(selectedItems)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, selectedItems]);
|
||||
|
||||
const handleToggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode((mode) => !mode);
|
||||
}, [setSelectionMode]);
|
||||
|
||||
const menu = selectedCount === 0 ?
|
||||
[
|
||||
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
|
||||
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
|
||||
] : [
|
||||
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptMultiple },
|
||||
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='column-header__select-row'>
|
||||
{selectionMode && (
|
||||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
|
||||
</div>
|
||||
)}
|
||||
<div className='column-header__select-row__selection-mode'>
|
||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||
{selectionMode ? (
|
||||
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
|
||||
) :
|
||||
(
|
||||
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{selectedCount > 0 &&
|
||||
<div className='column-header__select-row__selected-count'>
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
}
|
||||
<div className='column-header__select-row__actions'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SelectRow.propTypes = {
|
||||
selectAllChecked: PropTypes.func.isRequired,
|
||||
toggleSelectAll: PropTypes.func.isRequired,
|
||||
selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
selectionMode: PropTypes.bool,
|
||||
setSelectionMode: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const NotificationRequests = ({ multiColumn }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
@ -63,10 +196,40 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
|
||||
const [selectAllChecked, setSelectAllChecked] = useState(false);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, [columnRef]);
|
||||
|
||||
const handleCheck = useCallback(id => {
|
||||
setCheckedRequestIds(ids => {
|
||||
const position = ids.indexOf(id);
|
||||
|
||||
if(position > -1)
|
||||
ids.splice(position, 1);
|
||||
else
|
||||
ids.push(id);
|
||||
|
||||
setSelectAllChecked(ids.length === notificationRequests.size);
|
||||
|
||||
return [...ids];
|
||||
});
|
||||
}, [setCheckedRequestIds, notificationRequests]);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectAllChecked(checked => {
|
||||
if(checked)
|
||||
setCheckedRequestIds([]);
|
||||
else
|
||||
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
|
||||
|
||||
return !checked;
|
||||
});
|
||||
}, [notificationRequests]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandNotificationRequests());
|
||||
}, [dispatch]);
|
||||
|
@ -84,6 +247,8 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
appendContent={
|
||||
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />}
|
||||
>
|
||||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
@ -104,6 +269,9 @@ export const NotificationRequests = ({ multiColumn }) => {
|
|||
id={request.get('id')}
|
||||
accountId={request.get('account')}
|
||||
notificationsCount={request.get('notifications_count')}
|
||||
showCheckbox={selectionMode}
|
||||
checked={checkedRequestIds.includes(request.get('id'))}
|
||||
toggleCheck={handleCheck}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import type { StatusVisibility } from 'mastodon/api_types/statuses';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (values) => (
|
||||
const mentionLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.mention' defaultMessage='Mention' />
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.mention'
|
||||
defaultMessage='{name} mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_mention'
|
||||
defaultMessage='Private mention'
|
||||
/>
|
||||
);
|
||||
|
||||
const privateMentionLabelRenderer: LabelRenderer = (values) => (
|
||||
const replyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage id='notification.label.reply' defaultMessage='Reply' />
|
||||
);
|
||||
|
||||
const privateReplyLabelRenderer: LabelRenderer = () => (
|
||||
<FormattedMessage
|
||||
id='notification.private_mention'
|
||||
defaultMessage='{name} privately mentioned you'
|
||||
values={values}
|
||||
id='notification.label.private_reply'
|
||||
defaultMessage='Private reply'
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{
|
|||
notification: NotificationGroupMention;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const statusVisibility = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn([
|
||||
notification.statusId,
|
||||
'visibility',
|
||||
]) as StatusVisibility,
|
||||
);
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = state.statuses.get(notification.statusId) as Status;
|
||||
|
||||
return [
|
||||
status.get('visibility') === 'direct',
|
||||
status.get('in_reply_to_account_id') === me,
|
||||
] as const;
|
||||
});
|
||||
|
||||
let labelRenderer = mentionLabelRenderer;
|
||||
|
||||
if (isReply && isDirect) labelRenderer = privateReplyLabelRenderer;
|
||||
else if (isReply) labelRenderer = replyLabelRenderer;
|
||||
else if (isDirect) labelRenderer = privateMentionLabelRenderer;
|
||||
|
||||
return (
|
||||
<NotificationWithStatus
|
||||
type='mention'
|
||||
icon={statusVisibility === 'direct' ? AlternateEmailIcon : ReplyIcon}
|
||||
icon={isReply ? ReplyIcon : AlternateEmailIcon}
|
||||
iconId='reply'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={
|
||||
statusVisibility === 'direct'
|
||||
? privateMentionLabelRenderer
|
||||
: labelRenderer
|
||||
}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -4,8 +4,6 @@ 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';
|
||||
|
@ -26,16 +24,14 @@ import type { NotificationGap } from 'mastodon/reducers/notification_groups';
|
|||
import {
|
||||
selectUnreadNotificationGroupsCount,
|
||||
selectPendingNotificationGroupsCount,
|
||||
selectAnyPendingNotification,
|
||||
selectNotificationGroups,
|
||||
} 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';
|
||||
|
@ -61,41 +57,19 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
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 notifications = useAppSelector(selectNotificationGroups);
|
||||
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
|
||||
? s.notificationGroups.readMarkerId
|
||||
: '0',
|
||||
);
|
||||
|
||||
|
@ -105,11 +79,13 @@ export const Notifications: React.FC<{
|
|||
selectUnreadNotificationGroupsCount,
|
||||
);
|
||||
|
||||
const anyPendingNotification = useAppSelector(selectAnyPendingNotification);
|
||||
|
||||
const isUnread = unreadNotificationsCount > 0;
|
||||
|
||||
const canMarkAsRead =
|
||||
useAppSelector(selectSettingsNotificationsShowUnread) &&
|
||||
unreadNotificationsCount > 0;
|
||||
anyPendingNotification;
|
||||
|
||||
const needsNotificationPermission = useAppSelector(
|
||||
selectNeedsNotificationPermission,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
@ -18,6 +18,7 @@ import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
|
|||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
@ -598,7 +599,7 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
let ancestors, descendants, remoteHint;
|
||||
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
|
@ -627,6 +628,10 @@ class Status extends ImmutablePureComponent {
|
|||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
if (!isLocal) {
|
||||
remoteHint = <TimelineHint url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -695,6 +700,7 @@ class Status extends ImmutablePureComponent {
|
|||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||
import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react';
|
||||
import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react';
|
||||
import { closeModal } from 'mastodon/actions/modal';
|
||||
import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const IgnoreNotificationsModal = ({ filterType }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' }));
|
||||
}, [dispatch, filterType]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' }));
|
||||
}, [dispatch, filterType]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
let title = null;
|
||||
|
||||
switch(filterType) {
|
||||
case 'for_not_following':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.not_following_title' defaultMessage="Ignore notifications from people you don't follow?" />;
|
||||
break;
|
||||
case 'for_not_followers':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.not_followers_title' defaultMessage='Ignore notifications from people not following you?' />;
|
||||
break;
|
||||
case 'for_new_accounts':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.new_accounts_title' defaultMessage='Ignore notifications from new accounts?' />;
|
||||
break;
|
||||
case 'for_private_mentions':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.private_mentions_title' defaultMessage='Ignore notifications from unsolicited Private Mentions?' />;
|
||||
break;
|
||||
case 'for_limited_accounts':
|
||||
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={InventoryIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_review_separately' defaultMessage='You can review filtered notifications speparately' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonAlertIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_act_users' defaultMessage="You'll still be able to accept, reject, or report users" /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ShieldQuestionIcon} /></div>
|
||||
<div><FormattedMessage id='ignore_notifications_modal.filter_to_avoid_confusion' defaultMessage='Filtering helps avoid potential confusion' /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormattedMessage id='ignore_notifications_modal.disclaimer' defaultMessage="Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage id='ignore_notifications_modal.filter_instead' defaultMessage='Filter instead' />
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<button onClick={handleClick} className='link-button'>
|
||||
<FormattedMessage id='ignore_notifications_modal.ignore' defaultMessage='Ignore notifications' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
IgnoreNotificationsModal.propTypes = {
|
||||
filterType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default IgnoreNotificationsModal;
|
|
@ -17,6 +17,7 @@ import {
|
|||
InteractionModal,
|
||||
SubscribedLanguagesModal,
|
||||
ClosedRegistrationsModal,
|
||||
IgnoreNotificationsModal,
|
||||
} from 'mastodon/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
|
||||
|
@ -70,6 +71,7 @@ export const MODAL_COMPONENTS = {
|
|||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
'INTERACTION': InteractionModal,
|
||||
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
|
|
@ -134,6 +134,10 @@ export function ReportModal () {
|
|||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
||||
}
|
||||
|
||||
export function IgnoreNotificationsModal () {
|
||||
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
"about.not_available": "Hierdie inligting is nie op hierdie bediener beskikbaar gestel nie.",
|
||||
"about.powered_by": "Gedesentraliseerde sosiale media aangedryf deur {mastodon}",
|
||||
"about.rules": "Bedienerreëls",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.add_or_remove_from_list": "Voeg by of verwyder van lyste",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Groep",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Esta información no ye disponible en este servidor.",
|
||||
"about.powered_by": "Retz socials descentralizaus con tecnolochía de {mastodon}",
|
||||
"about.rules": "Reglas d'o servidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.add_or_remove_from_list": "Adhibir u eliminar de listas",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Grupo",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "لم يتم توفير هذه المعلومات على هذا الخادم.",
|
||||
"about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}",
|
||||
"about.rules": "قواعد الخادم",
|
||||
"account.account_note_header": "مُلاحظة",
|
||||
"account.add_or_remove_from_list": "الإضافة أو الإزالة من القائمة",
|
||||
"account.badges.bot": "آلي",
|
||||
"account.badges.group": "فريق",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Esta información nun ta disponible nesti sirvidor.",
|
||||
"about.powered_by": "Una rede social descentralizada que tien la teunoloxía de {mastodon}",
|
||||
"about.rules": "Normes del sirvidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.add_or_remove_from_list": "Amestar o quitar de les llistes",
|
||||
"account.badges.group": "Grupu",
|
||||
"account.block": "Bloquiar a @{name}",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.",
|
||||
"about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}",
|
||||
"about.rules": "Правілы сервера",
|
||||
"account.account_note_header": "Нататка",
|
||||
"account.add_or_remove_from_list": "Дадаць або выдаліць са спісаў",
|
||||
"account.badges.bot": "Бот",
|
||||
"account.badges.group": "Група",
|
||||
|
@ -171,21 +170,28 @@
|
|||
"confirmations.block.confirm": "Заблакіраваць",
|
||||
"confirmations.delete.confirm": "Выдаліць",
|
||||
"confirmations.delete.message": "Вы ўпэўненыя, што хочаце выдаліць гэты допіс?",
|
||||
"confirmations.delete.title": "Выдаліць допіс?",
|
||||
"confirmations.delete_list.confirm": "Выдаліць",
|
||||
"confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?",
|
||||
"confirmations.delete_list.title": "Выдаліць спіс?",
|
||||
"confirmations.discard_edit_media.confirm": "Адмяніць",
|
||||
"confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?",
|
||||
"confirmations.edit.confirm": "Рэдагаваць",
|
||||
"confirmations.edit.message": "Калі вы зменіце зараз, гэта ператрэ паведамленне, якое вы пішаце. Вы ўпэўнены, што хочаце працягнуць?",
|
||||
"confirmations.edit.title": "Замяніць допіс?",
|
||||
"confirmations.logout.confirm": "Выйсці",
|
||||
"confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?",
|
||||
"confirmations.logout.title": "Выйсці?",
|
||||
"confirmations.mute.confirm": "Ігнараваць",
|
||||
"confirmations.redraft.confirm": "Выдаліць і перапісаць",
|
||||
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
||||
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
||||
"confirmations.reply.confirm": "Адказаць",
|
||||
"confirmations.reply.message": "Калі вы адкажаце зараз, гэта ператрэ паведамленне, якое вы пішаце. Вы ўпэўнены, што хочаце працягнуць?",
|
||||
"confirmations.reply.title": "Замяніць допіс?",
|
||||
"confirmations.unfollow.confirm": "Адпісацца",
|
||||
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
|
||||
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
|
||||
"conversation.delete": "Выдаліць размову",
|
||||
"conversation.mark_as_read": "Адзначыць прачытаным",
|
||||
"conversation.open": "Прагледзець размову",
|
||||
|
@ -293,6 +299,7 @@
|
|||
"filter_modal.select_filter.subtitle": "Скарыстайцеся існуючай катэгорыяй або стварыце новую",
|
||||
"filter_modal.select_filter.title": "Фільтраваць гэты допіс",
|
||||
"filter_modal.title.status": "Фільтраваць допіс",
|
||||
"filtered_notifications_banner.pending_requests": "Ад {count, plural, =0 {# людзей якіх} one {# чалавека якіх} few {# чалавек якіх} many {# людзей якіх} other {# чалавека якіх}} вы магчыма ведаеце",
|
||||
"filtered_notifications_banner.title": "Адфільтраваныя апавяшчэнні",
|
||||
"firehose.all": "Усе",
|
||||
"firehose.local": "Гэты сервер",
|
||||
|
@ -341,7 +348,7 @@
|
|||
"hashtag.follow": "Падпісацца на хэштэг",
|
||||
"hashtag.unfollow": "Адпісацца ад хэштэга",
|
||||
"hashtags.and_other": "…і яшчэ {count, plural, other {#}}",
|
||||
"home.column_settings.show_reblogs": "Паказаць пашырэнні",
|
||||
"home.column_settings.show_reblogs": "Паказваць пашырэнні",
|
||||
"home.column_settings.show_replies": "Паказваць адказы",
|
||||
"home.hide_announcements": "Схаваць аб'явы",
|
||||
"home.pending_critical_update.body": "Калі ласка, абнавіце свой сервер Mastodon як мага хутчэй!",
|
||||
|
@ -437,6 +444,8 @@
|
|||
"mute_modal.title": "Ігнараваць карыстальніка?",
|
||||
"mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.",
|
||||
"mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.",
|
||||
"name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}}",
|
||||
"name_and_others_with_link": "{name} і <a>{count, plural, one {# іншы} many {# іншых} other {# іншых}}</a>",
|
||||
"navigation_bar.about": "Пра нас",
|
||||
"navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе",
|
||||
"navigation_bar.blocks": "Заблакіраваныя карыстальнікі",
|
||||
|
@ -464,6 +473,10 @@
|
|||
"navigation_bar.security": "Бяспека",
|
||||
"not_signed_in_indicator.not_signed_in": "Вам трэба ўвайсці каб атрымаць доступ да гэтага рэсурсу.",
|
||||
"notification.admin.report": "{name} паскардзіўся на {target}",
|
||||
"notification.admin.report_account": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}",
|
||||
"notification.admin.report_account_other": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target}",
|
||||
"notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}",
|
||||
"notification.admin.report_statuses_other": "{name} паскардзіўся на {target}",
|
||||
"notification.admin.sign_up": "{name} зарэгістраваўся",
|
||||
"notification.favourite": "Ваш допіс упадабаны {name}",
|
||||
"notification.follow": "{name} падпісаўся на вас",
|
||||
|
@ -479,6 +492,8 @@
|
|||
"notification.moderation_warning.action_silence": "Ваш уліковы запіс быў абмежаваны.",
|
||||
"notification.moderation_warning.action_suspend": "Ваш уліковы запіс быў прыпынены.",
|
||||
"notification.own_poll": "Ваша апытанне скончылася",
|
||||
"notification.poll": "Апытанне, дзе вы прынялі ўдзел, скончылася",
|
||||
"notification.private_mention": "{name} згадаў вас асабіста",
|
||||
"notification.reblog": "{name} пашырыў ваш допіс",
|
||||
"notification.relationships_severance_event": "Страціў сувязь з {name}",
|
||||
"notification.relationships_severance_event.account_suspension": "Адміністратар з {from} прыпыніў працу {target}, што азначае, што вы больш не можаце атрымліваць ад іх абнаўлення ці ўзаемадзейнічаць з імі.",
|
||||
|
@ -489,13 +504,18 @@
|
|||
"notification.update": "Допіс {name} адрэдагаваны",
|
||||
"notification_requests.accept": "Прыняць",
|
||||
"notification_requests.dismiss": "Адхіліць",
|
||||
"notification_requests.maximize": "Разгарнуць",
|
||||
"notification_requests.minimize_banner": "Згарнуць банер адфільтраваных апавяшчэнняў",
|
||||
"notification_requests.notifications_from": "Апавяшчэнні ад {name}",
|
||||
"notification_requests.title": "Адфільтраваныя апавяшчэнні",
|
||||
"notifications.clear": "Ачысціць апавяшчэнні",
|
||||
"notifications.clear_confirmation": "Вы ўпэўнены, што жадаеце назаўсёды сцерці ўсё паведамленні?",
|
||||
"notifications.clear_title": "Ачысціць апавяшчэнні?",
|
||||
"notifications.column_settings.admin.report": "Новыя скаргі:",
|
||||
"notifications.column_settings.admin.sign_up": "Новыя ўваходы:",
|
||||
"notifications.column_settings.alert": "Апавяшчэнні на працоўным стале",
|
||||
"notifications.column_settings.beta.category": "Эксперыментальныя функцыі",
|
||||
"notifications.column_settings.beta.grouping": "Групаваць апавяшчэннi",
|
||||
"notifications.column_settings.favourite": "Упадабанае:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Паказаць усе катэгорыі",
|
||||
"notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі",
|
||||
|
@ -659,9 +679,13 @@
|
|||
"report.unfollow_explanation": "Вы падпісаныя на гэты ўліковы запіс. Каб не бачыць допісы з яго ў вашай стужцы, адпішыцеся.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} допіс прымацаваны} few {{count} допісы прымацаваны} many {{count} допісаў прымацавана} other {{count} допісу прымацавана}}",
|
||||
"report_notification.categories.legal": "Права",
|
||||
"report_notification.categories.legal_sentence": "нелегальнае змесціва",
|
||||
"report_notification.categories.other": "Іншае",
|
||||
"report_notification.categories.other_sentence": "іншае",
|
||||
"report_notification.categories.spam": "Спам",
|
||||
"report_notification.categories.spam_sentence": "спам",
|
||||
"report_notification.categories.violation": "Парушэнне правілаў",
|
||||
"report_notification.categories.violation_sentence": "парушэнне правілаў",
|
||||
"report_notification.open": "Адкрыць скаргу",
|
||||
"search.no_recent_searches": "Гісторыя пошуку пустая",
|
||||
"search.placeholder": "Пошук",
|
||||
|
@ -689,8 +713,11 @@
|
|||
"server_banner.about_active_users": "Людзі, якія карыстаюцца гэтым сервера на працягу апошніх 30 дзён (Штомесячна Актыўныя Карыстальнікі)",
|
||||
"server_banner.active_users": "актыўныя карыстальнікі",
|
||||
"server_banner.administered_by": "Адміністратар:",
|
||||
"server_banner.is_one_of_many": "{domain} - гэта адзін з многіх незалежных сервераў Mastodon, якія вы можаце выкарыстоўваць для ўдзелу ў fediverse.",
|
||||
"server_banner.server_stats": "Статыстыка сервера:",
|
||||
"sign_in_banner.create_account": "Стварыць уліковы запіс",
|
||||
"sign_in_banner.follow_anyone": "Сачыце за кім заўгодна ва ўсім fediverse і глядзіце ўсё ў храналагічным парадку. Ніякіх алгарытмаў, рэкламы або клікбэйту.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon - лепшы спосаб быць у курсе ўсяго, што адбываецца.",
|
||||
"sign_in_banner.sign_in": "Увайсці",
|
||||
"sign_in_banner.sso_redirect": "Уваход ці рэгістрацыя",
|
||||
"status.admin_account": "Адкрыць інтэрфейс мадэратара для @{name}",
|
||||
|
@ -765,8 +792,8 @@
|
|||
"time_remaining.seconds": "{number, plural, one {засталася # секунда} few {засталося # секунды} many {засталося # секунд} other {засталося # секунды}}",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} з іншых сервераў не адлюстроўваецца.",
|
||||
"timeline_hint.resources.followers": "Падпісчыкі",
|
||||
"timeline_hint.resources.follows": "Падпісаны на",
|
||||
"timeline_hint.resources.statuses": "Старэйшыя допісы",
|
||||
"timeline_hint.resources.follows": "Падпіскі",
|
||||
"timeline_hint.resources.statuses": "Старыя допісы",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} чалавек} few {{counter} чалавекі} many {{counter} людзей} other {{counter} чалавек}} за {days, plural, one {{days} апошні дзень} few {{days} апошнія дні} many {{days} апошніх дзён} other {{days} апошніх дзён}}",
|
||||
"trends.trending_now": "Актуальнае",
|
||||
"ui.beforeunload": "Ваш чарнавік знішчыцца калі вы пакінеце Mastodon.",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Тази информация не е публична на този сървър.",
|
||||
"about.powered_by": "Децентрализирана социална мрежа, захранвана от {mastodon}",
|
||||
"about.rules": "Правила на сървъра",
|
||||
"account.account_note_header": "Бележка",
|
||||
"account.account_note_header": "Лична бележка",
|
||||
"account.add_or_remove_from_list": "Добавяне или премахване от списъци",
|
||||
"account.badges.bot": "Бот",
|
||||
"account.badges.group": "Група",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} промени публикация",
|
||||
"notification_requests.accept": "Приемам",
|
||||
"notification_requests.dismiss": "Отхвърлям",
|
||||
"notification_requests.maximize": "Максимизиране",
|
||||
"notification_requests.minimize_banner": "Минимизиране на банера за филтрирани известия",
|
||||
"notification_requests.notifications_from": "Известия от {name}",
|
||||
"notification_requests.title": "Филтрирани известия",
|
||||
"notifications.clear": "Изчистване на известията",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "এই তথ্য এই সার্ভারে উন্মুক্ত করা হয়নি.",
|
||||
"about.powered_by": "{mastodon} দ্বারা তৈরি বিকেন্দ্রীভূত সামাজিক মিডিয়া।",
|
||||
"about.rules": "সার্ভারের নিয়মাবলী",
|
||||
"account.account_note_header": "বিজ্ঞপ্তি",
|
||||
"account.add_or_remove_from_list": "তালিকাতে যোগ বা অপসারণ করো",
|
||||
"account.badges.bot": "বট",
|
||||
"account.badges.group": "দল",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "An titour-mañ ne c'heller ket gwelet war ar servijer-mañ.",
|
||||
"about.powered_by": "Rouedad sokial digreizenned kaset gant {mastodon}",
|
||||
"about.rules": "Reolennoù ar servijer",
|
||||
"account.account_note_header": "Notenn",
|
||||
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.group": "Strollad",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Aquesta informació no és disponible en aquest servidor.",
|
||||
"about.powered_by": "Xarxa social descentralitzada impulsada per {mastodon}",
|
||||
"about.rules": "Normes del servidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.account_note_header": "Nota personal",
|
||||
"account.add_or_remove_from_list": "Afegeix o elimina de les llistes",
|
||||
"account.badges.bot": "Automatitzat",
|
||||
"account.badges.group": "Grup",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} ha editat un tut",
|
||||
"notification_requests.accept": "Accepta",
|
||||
"notification_requests.dismiss": "Ignora",
|
||||
"notification_requests.maximize": "Maximitza",
|
||||
"notification_requests.minimize_banner": "Minimitza el bàner de notificacions filtrades",
|
||||
"notification_requests.notifications_from": "Notificacions de {name}",
|
||||
"notification_requests.title": "Notificacions filtrades",
|
||||
"notifications.clear": "Esborra les notificacions",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "ئەم زانیاریانە لەسەر ئەم سێرڤەرە بەردەست نەکراون.",
|
||||
"about.powered_by": "سۆشیال میدیای لامەرکەزی کە لەلایەن {mastodon} ەوە بەهێز دەکرێت",
|
||||
"about.rules": "یاساکانی سێرڤەر",
|
||||
"account.account_note_header": "تێبینی ",
|
||||
"account.add_or_remove_from_list": "زیادکردن یان سڕینەوە لە پێرستەکان",
|
||||
"account.badges.bot": "بوت",
|
||||
"account.badges.group": "گرووپ",
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"account.account_note_header": "Nota",
|
||||
"account.add_or_remove_from_list": "Aghjunghje o toglie da e liste",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Gruppu",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Tato informace nebyla zpřístupněna na tomto serveru.",
|
||||
"about.powered_by": "Decentralizovaná sociální média poháněná {mastodon}",
|
||||
"about.rules": "Pravidla serveru",
|
||||
"account.account_note_header": "Poznámka",
|
||||
"account.account_note_header": "Osobní poznámka",
|
||||
"account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Skupina",
|
||||
|
@ -171,21 +171,28 @@
|
|||
"confirmations.block.confirm": "Blokovat",
|
||||
"confirmations.delete.confirm": "Smazat",
|
||||
"confirmations.delete.message": "Opravdu chcete smazat tento příspěvek?",
|
||||
"confirmations.delete.title": "Smazat příspěvek?",
|
||||
"confirmations.delete_list.confirm": "Smazat",
|
||||
"confirmations.delete_list.message": "Opravdu chcete tento seznam navždy smazat?",
|
||||
"confirmations.delete_list.title": "Smazat seznam?",
|
||||
"confirmations.discard_edit_media.confirm": "Zahodit",
|
||||
"confirmations.discard_edit_media.message": "Máte neuložené změny popisku médií nebo náhledu, chcete je přesto zahodit?",
|
||||
"confirmations.edit.confirm": "Upravit",
|
||||
"confirmations.edit.message": "Editovat teď znamená přepsání zprávy, kterou právě tvoříte. Opravdu chcete pokračovat?",
|
||||
"confirmations.edit.title": "Přepsat příspěvek?",
|
||||
"confirmations.logout.confirm": "Odhlásit se",
|
||||
"confirmations.logout.message": "Opravdu se chcete odhlásit?",
|
||||
"confirmations.logout.title": "Odhlásit se?",
|
||||
"confirmations.mute.confirm": "Skrýt",
|
||||
"confirmations.redraft.confirm": "Smazat a přepsat",
|
||||
"confirmations.redraft.message": "Jste si jistí, že chcete odstranit tento příspěvek a vytvořit z něj koncept? Oblíbené a boosty budou ztraceny a odpovědi na původní příspěvek ztratí kontext.",
|
||||
"confirmations.redraft.title": "Smazat a přepracovat příspěvek na koncept?",
|
||||
"confirmations.reply.confirm": "Odpovědět",
|
||||
"confirmations.reply.message": "Odpověď přepíše vaši rozepsanou zprávu. Opravdu chcete pokračovat?",
|
||||
"confirmations.reply.title": "Přepsat příspěvek?",
|
||||
"confirmations.unfollow.confirm": "Přestat sledovat",
|
||||
"confirmations.unfollow.message": "Opravdu chcete {name} přestat sledovat?",
|
||||
"confirmations.unfollow.title": "Přestat sledovat uživatele?",
|
||||
"conversation.delete": "Smazat konverzaci",
|
||||
"conversation.mark_as_read": "Označit jako přečtené",
|
||||
"conversation.open": "Zobrazit konverzaci",
|
||||
|
@ -464,6 +471,8 @@
|
|||
"navigation_bar.security": "Zabezpečení",
|
||||
"not_signed_in_indicator.not_signed_in": "Pro přístup k tomuto zdroji se musíte přihlásit.",
|
||||
"notification.admin.report": "Uživatel {name} nahlásil {target}",
|
||||
"notification.admin.report_statuses": "{name} nahlásil {target} za {category}",
|
||||
"notification.admin.report_statuses_other": "{name} nahlásil {target}",
|
||||
"notification.admin.sign_up": "Uživatel {name} se zaregistroval",
|
||||
"notification.favourite": "Uživatel {name} si oblíbil váš příspěvek",
|
||||
"notification.follow": "Uživatel {name} vás začal sledovat",
|
||||
|
@ -479,6 +488,8 @@
|
|||
"notification.moderation_warning.action_silence": "Váš účet byl omezen.",
|
||||
"notification.moderation_warning.action_suspend": "Váš účet byl pozastaven.",
|
||||
"notification.own_poll": "Vaše anketa skončila",
|
||||
"notification.poll": "Anketa, ve které jste hlasovali, skončila",
|
||||
"notification.private_mention": "{name} vás soukromě zmínil",
|
||||
"notification.reblog": "Uživatel {name} boostnul váš příspěvek",
|
||||
"notification.relationships_severance_event": "Kontakt ztracen s {name}",
|
||||
"notification.relationships_severance_event.account_suspension": "Administrátor z {from} pozastavil {target}, což znamená, že již od nich nemůžete přijímat aktualizace nebo s nimi interagovat.",
|
||||
|
@ -489,13 +500,18 @@
|
|||
"notification.update": "Uživatel {name} upravil příspěvek",
|
||||
"notification_requests.accept": "Přijmout",
|
||||
"notification_requests.dismiss": "Zamítnout",
|
||||
"notification_requests.maximize": "Maximalizovat",
|
||||
"notification_requests.minimize_banner": "Minimalizovat banner filtrovaných oznámení",
|
||||
"notification_requests.notifications_from": "Oznámení od {name}",
|
||||
"notification_requests.title": "Vyfiltrovaná oznámení",
|
||||
"notifications.clear": "Vyčistit oznámení",
|
||||
"notifications.clear_confirmation": "Opravdu chcete trvale smazat všechna vaše oznámení?",
|
||||
"notifications.clear_title": "Vyčistit oznámení?",
|
||||
"notifications.column_settings.admin.report": "Nová hlášení:",
|
||||
"notifications.column_settings.admin.sign_up": "Nové registrace:",
|
||||
"notifications.column_settings.alert": "Oznámení na počítači",
|
||||
"notifications.column_settings.beta.category": "Experimentální funkce",
|
||||
"notifications.column_settings.beta.grouping": "Seskupit notifikace",
|
||||
"notifications.column_settings.favourite": "Oblíbené:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Zobrazit všechny kategorie",
|
||||
"notifications.column_settings.filter_bar.category": "Panel rychlého filtrování",
|
||||
|
@ -659,9 +675,13 @@
|
|||
"report.unfollow_explanation": "Tento účet sledujete. Abyste už neviděli jeho příspěvky ve své domovské časové ose, přestaňte jej sledovat.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} připojený příspěvek} few {{count} připojené příspěvky} many {{count} připojených příspěvků} other {{count} připojených příspěvků}}",
|
||||
"report_notification.categories.legal": "Právní ustanovení",
|
||||
"report_notification.categories.legal_sentence": "nezákonný obsah",
|
||||
"report_notification.categories.other": "Ostatní",
|
||||
"report_notification.categories.other_sentence": "další",
|
||||
"report_notification.categories.spam": "Spam",
|
||||
"report_notification.categories.spam_sentence": "spam",
|
||||
"report_notification.categories.violation": "Porušení pravidla",
|
||||
"report_notification.categories.violation_sentence": "porušení pravidla",
|
||||
"report_notification.open": "Otevřít hlášení",
|
||||
"search.no_recent_searches": "Žádná nedávná vyhledávání",
|
||||
"search.placeholder": "Hledat",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"about.contact": "Cysylltwch â:",
|
||||
"about.disclaimer": "Mae Mastodon yn feddalwedd cod agored rhydd ac o dan hawlfraint Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Nid yw'r rheswm ar gael",
|
||||
"about.domain_blocks.preamble": "Fel rheol, mae Mastodon yn caniatáu i chi weld cynnwys gan unrhyw weinyddwr arall yn y ffederasiwn a rhyngweithio â hi. Dyma'r eithriadau a wnaed ar y gweinydd penodol hwn.",
|
||||
"about.domain_blocks.preamble": "Fel rheol, mae Mastodon yn caniatáu i chi weld cynnwys gan unrhyw weinyddwr arall yn y ffedysawd a rhyngweithio â hi. Dyma'r eithriadau a wnaed ar y gweinydd penodol hwn.",
|
||||
"about.domain_blocks.silenced.explanation": "Fel rheol, fyddwch chi ddim yn gweld proffiliau a chynnwys o'r gweinydd hwn, oni bai eich bod yn chwilio'n benodol amdano neu yn ymuno drwy ei ddilyn.",
|
||||
"about.domain_blocks.silenced.title": "Cyfyngedig",
|
||||
"about.domain_blocks.suspended.explanation": "Ni fydd data o'r gweinydd hwn yn cael ei brosesu, ei gadw na'i gyfnewid, gan wneud unrhyw ryngweithio neu gyfathrebu gyda defnyddwyr o'r gweinydd hwn yn amhosibl.",
|
||||
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Nid yw'r wybodaeth hon ar gael ar y gweinydd hwn.",
|
||||
"about.powered_by": "Cyfrwng cymdeithasol datganoledig wedi ei yrru gan {mastodon}",
|
||||
"about.rules": "Rheolau'r gweinydd",
|
||||
"account.account_note_header": "Nodyn",
|
||||
"account.add_or_remove_from_list": "Ychwanegu neu Ddileu o'r rhestrau",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Grŵp",
|
||||
|
@ -503,6 +502,7 @@
|
|||
"notification.update": "Golygodd {name} bostiad",
|
||||
"notification_requests.accept": "Derbyn",
|
||||
"notification_requests.dismiss": "Cau",
|
||||
"notification_requests.maximize": "Mwyhau",
|
||||
"notification_requests.notifications_from": "Hysbysiadau gan {name}",
|
||||
"notification_requests.title": "Hysbysiadau wedi'u hidlo",
|
||||
"notifications.clear": "Clirio hysbysiadau",
|
||||
|
@ -710,8 +710,11 @@
|
|||
"server_banner.about_active_users": "Pobl sy'n defnyddio'r gweinydd hwn yn ystod y 30 diwrnod diwethaf (Defnyddwyr Gweithredol Misol)",
|
||||
"server_banner.active_users": "defnyddwyr gweithredol",
|
||||
"server_banner.administered_by": "Gweinyddir gan:",
|
||||
"server_banner.is_one_of_many": "Mae {domain} yn un o'r nifer o weinyddion Mastodon annibynnol y gallwch eu defnyddio i gymryd rhan yn y ffedysawd.",
|
||||
"server_banner.server_stats": "Ystadegau'r gweinydd:",
|
||||
"sign_in_banner.create_account": "Creu cyfrif",
|
||||
"sign_in_banner.follow_anyone": "Dilynwch unrhyw un ar draws y ffedysawd a gweld y cyfan mewn trefn gronolegol. Dim algorithmau, hysbysebion, na straeon er mwyn cliciadau yn y golwg.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon yw'r ffordd orau o gadw i fyny â'r hyn sy'n digwydd.",
|
||||
"sign_in_banner.sign_in": "Mewngofnodi",
|
||||
"sign_in_banner.sso_redirect": "Mewngofnodi neu Gofrestru",
|
||||
"status.admin_account": "Agor rhyngwyneb cymedroli ar gyfer @{name}",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Denne information er ikke blevet gjort tilgængelig på denne server.",
|
||||
"about.powered_by": "Decentraliserede sociale medier drevet af {mastodon}",
|
||||
"about.rules": "Serverregler",
|
||||
"account.account_note_header": "Note",
|
||||
"account.account_note_header": "Personligt notat",
|
||||
"account.add_or_remove_from_list": "Tilføj eller fjern fra lister",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Gruppe",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} redigerede et indlæg",
|
||||
"notification_requests.accept": "Acceptér",
|
||||
"notification_requests.dismiss": "Afvis",
|
||||
"notification_requests.maximize": "Maksimér",
|
||||
"notification_requests.minimize_banner": "Minimér filtrerede notifikationsbanner",
|
||||
"notification_requests.notifications_from": "Notifikationer fra {name}",
|
||||
"notification_requests.title": "Filtrerede notifikationer",
|
||||
"notifications.clear": "Ryd notifikationer",
|
||||
|
@ -543,6 +545,8 @@
|
|||
"notifications.permission_denied": "Computernotifikationer er utilgængelige grundet tidligere afvist browsertilladelsesanmodning",
|
||||
"notifications.permission_denied_alert": "Computernotifikationer kan ikke aktiveres, da browsertilladelse tidligere blev nægtet",
|
||||
"notifications.permission_required": "Computernotifikationer er utilgængelige, da den krævede tilladelse ikke er tildelt.",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Begrænset af servermoderatorer",
|
||||
"notifications.policy.filter_limited_accounts_title": "Modererede konti",
|
||||
"notifications.policy.filter_new_accounts.hint": "Oprettet indenfor {days, plural, one {den seneste dag} other {de seneste # dage}}",
|
||||
"notifications.policy.filter_new_accounts_title": "Ny konti",
|
||||
"notifications.policy.filter_not_followers_hint": "Inklusiv personer, som har fulgt dig {days, plural, one {mindre end én dag} other {færre end # dage}}",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Diese Informationen sind auf diesem Server nicht verfügbar.",
|
||||
"about.powered_by": "Ein dezentralisiertes soziales Netzwerk, angetrieben von {mastodon}",
|
||||
"about.rules": "Serverregeln",
|
||||
"account.account_note_header": "Notiz",
|
||||
"account.account_note_header": "Persönliche Notiz",
|
||||
"account.add_or_remove_from_list": "Hinzufügen oder Entfernen von Listen",
|
||||
"account.badges.bot": "Automatisiert",
|
||||
"account.badges.group": "Gruppe",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} bearbeitete einen Beitrag",
|
||||
"notification_requests.accept": "Akzeptieren",
|
||||
"notification_requests.dismiss": "Ablehnen",
|
||||
"notification_requests.maximize": "Maximieren",
|
||||
"notification_requests.minimize_banner": "Banner für gefilterte Benachrichtigungen minimieren",
|
||||
"notification_requests.notifications_from": "Benachrichtigungen von {name}",
|
||||
"notification_requests.title": "Gefilterte Benachrichtigungen",
|
||||
"notifications.clear": "Benachrichtigungen löschen",
|
||||
|
@ -687,7 +689,7 @@
|
|||
"report_notification.categories.violation_sentence": "Regelverletzung",
|
||||
"report_notification.open": "Meldung öffnen",
|
||||
"search.no_recent_searches": "Keine früheren Suchanfragen",
|
||||
"search.placeholder": "Suche",
|
||||
"search.placeholder": "Suchen",
|
||||
"search.quick_action.account_search": "Profile passend zu {x}",
|
||||
"search.quick_action.go_to_account": "Profil {x} aufrufen",
|
||||
"search.quick_action.go_to_hashtag": "Hashtag {x} aufrufen",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Αυτές οι πληροφορίες δεν έχουν είναι διαθέσιμες σε αυτόν τον διακομιστή.",
|
||||
"about.powered_by": "Αποκεντρωμένα μέσα κοινωνικής δικτύωσης που βασίζονται στο {mastodon}",
|
||||
"about.rules": "Κανόνες διακομιστή",
|
||||
"account.account_note_header": "Σημείωση",
|
||||
"account.account_note_header": "Προσωπική σημείωση",
|
||||
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
|
||||
"account.badges.bot": "Αυτοματοποιημένος",
|
||||
"account.badges.group": "Ομάδα",
|
||||
|
@ -171,21 +171,28 @@
|
|||
"confirmations.block.confirm": "Αποκλεισμός",
|
||||
"confirmations.delete.confirm": "Διαγραφή",
|
||||
"confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή τη δημοσίευση;",
|
||||
"confirmations.delete.title": "Διαγραφή ανάρτησης;",
|
||||
"confirmations.delete_list.confirm": "Διαγραφή",
|
||||
"confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
|
||||
"confirmations.delete_list.title": "Διαγραφή λίστας;",
|
||||
"confirmations.discard_edit_media.confirm": "Απόρριψη",
|
||||
"confirmations.discard_edit_media.message": "Έχεις μη αποθηκευμένες αλλαγές στην περιγραφή πολυμέσων ή στην προεπισκόπηση, απόρριψη ούτως ή άλλως;",
|
||||
"confirmations.edit.confirm": "Επεξεργασία",
|
||||
"confirmations.edit.message": "Αν το επεξεργαστείς τώρα θα αντικατασταθεί το μήνυμα που συνθέτεις. Είσαι σίγουρος ότι θέλεις να συνεχίσεις;",
|
||||
"confirmations.edit.title": "Αντικατάσταση ανάρτησης;",
|
||||
"confirmations.logout.confirm": "Αποσύνδεση",
|
||||
"confirmations.logout.message": "Σίγουρα θέλεις να αποσυνδεθείς;",
|
||||
"confirmations.logout.title": "Αποσύνδεση;",
|
||||
"confirmations.mute.confirm": "Αποσιώπηση",
|
||||
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
|
||||
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
|
||||
"confirmations.redraft.title": "Διαγραφή & επανασύνταξη;",
|
||||
"confirmations.reply.confirm": "Απάντησε",
|
||||
"confirmations.reply.message": "Απαντώντας τώρα θα αντικαταστήσεις το κείμενο που ήδη γράφεις. Σίγουρα θέλεις να συνεχίσεις;",
|
||||
"confirmations.reply.title": "Αντικατάσταση ανάρτησης;",
|
||||
"confirmations.unfollow.confirm": "Άρση ακολούθησης",
|
||||
"confirmations.unfollow.message": "Σίγουρα θες να πάψεις να ακολουθείς τον/την {name};",
|
||||
"confirmations.unfollow.title": "Άρση ακολούθησης;",
|
||||
"conversation.delete": "Διαγραφή συζήτησης",
|
||||
"conversation.mark_as_read": "Σήμανση ως αναγνωσμένο",
|
||||
"conversation.open": "Προβολή συνομιλίας",
|
||||
|
@ -293,6 +300,7 @@
|
|||
"filter_modal.select_filter.subtitle": "Χρησιμοποιήστε μια υπάρχουσα κατηγορία ή δημιουργήστε μια νέα",
|
||||
"filter_modal.select_filter.title": "Φιλτράρισμα αυτής της ανάρτησης",
|
||||
"filter_modal.title.status": "Φιλτράρισμα μιας ανάρτησης",
|
||||
"filtered_notifications_banner.pending_requests": "Από {count, plural, =0 {κανένα} one {ένα άτομο} other {# άτομα}} που μπορεί να ξέρεις",
|
||||
"filtered_notifications_banner.title": "Φιλτραρισμένες ειδοποιήσεις",
|
||||
"firehose.all": "Όλα",
|
||||
"firehose.local": "Αυτός ο διακομιστής",
|
||||
|
@ -497,10 +505,13 @@
|
|||
"notification.update": "ο/η {name} επεξεργάστηκε μια ανάρτηση",
|
||||
"notification_requests.accept": "Αποδοχή",
|
||||
"notification_requests.dismiss": "Απόρριψη",
|
||||
"notification_requests.maximize": "Μεγιστοποίηση",
|
||||
"notification_requests.minimize_banner": "Ελαχιστοποίηση μπάνερ φιλτραρισμένων ειδοποιήσεων",
|
||||
"notification_requests.notifications_from": "Ειδοποιήσεις από {name}",
|
||||
"notification_requests.title": "Φιλτραρισμένες ειδοποιήσεις",
|
||||
"notifications.clear": "Καθαρισμός ειδοποιήσεων",
|
||||
"notifications.clear_confirmation": "Σίγουρα θέλεις να καθαρίσεις μόνιμα όλες τις ειδοποιήσεις σου;",
|
||||
"notifications.clear_title": "Εκκαθάριση ειδοποιήσεων;",
|
||||
"notifications.column_settings.admin.report": "Νέες αναφορές:",
|
||||
"notifications.column_settings.admin.sign_up": "Νέες εγγραφές:",
|
||||
"notifications.column_settings.alert": "Ειδοποιήσεις επιφάνειας εργασίας",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "This information has not been made available on this server.",
|
||||
"about.powered_by": "Decentralised social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||
"account.badges.bot": "Automated",
|
||||
"account.badges.group": "Group",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "This information has not been made available on this server.",
|
||||
"about.powered_by": "Decentralized social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
"account.account_note_header": "Note",
|
||||
"account.account_note_header": "Personal note",
|
||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||
"account.badges.bot": "Automated",
|
||||
"account.badges.group": "Group",
|
||||
|
@ -356,6 +356,17 @@
|
|||
"home.pending_critical_update.link": "See updates",
|
||||
"home.pending_critical_update.title": "Critical security update available!",
|
||||
"home.show_announcements": "Show announcements",
|
||||
"ignore_notifications_modal.disclaimer": "Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent.",
|
||||
"ignore_notifications_modal.filter_instead": "Filter instead",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Filtering helps avoid potential confusion",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtering helps avoid potential confusion",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "You can review filtered notifications speparately",
|
||||
"ignore_notifications_modal.ignore": "Ignore notifications",
|
||||
"ignore_notifications_modal.limited_accounts_title": "Ignore notifications from moderated accounts?",
|
||||
"ignore_notifications_modal.new_accounts_title": "Ignore notifications from new accounts?",
|
||||
"ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?",
|
||||
"ignore_notifications_modal.not_following_title": "Ignore notifications from people you don't follow?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?",
|
||||
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
|
||||
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
|
||||
"interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.",
|
||||
|
@ -482,7 +493,11 @@
|
|||
"notification.favourite": "{name} favorited your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
"notification.label.mention": "Mention",
|
||||
"notification.label.private_mention": "Private mention",
|
||||
"notification.label.private_reply": "Private reply",
|
||||
"notification.label.reply": "Reply",
|
||||
"notification.mention": "Mention",
|
||||
"notification.moderation-warning.learn_more": "Learn more",
|
||||
"notification.moderation_warning": "You have received a moderation warning",
|
||||
"notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.",
|
||||
|
@ -494,7 +509,6 @@
|
|||
"notification.moderation_warning.action_suspend": "Your account has been suspended.",
|
||||
"notification.own_poll": "Your poll 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.",
|
||||
|
@ -504,11 +518,26 @@
|
|||
"notification.status": "{name} just posted",
|
||||
"notification.update": "{name} edited a post",
|
||||
"notification_requests.accept": "Accept",
|
||||
"notification_requests.accept_all": "Accept all",
|
||||
"notification_requests.accept_multiple": "{count, plural, one {Accept # request} other {Accept # requests}}",
|
||||
"notification_requests.confirm_accept_all.button": "Accept all",
|
||||
"notification_requests.confirm_accept_all.message": "You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_accept_all.title": "Accept notification requests?",
|
||||
"notification_requests.confirm_dismiss_all.button": "Dismiss all",
|
||||
"notification_requests.confirm_dismiss_all.message": "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_dismiss_all.title": "Dismiss notification requests?",
|
||||
"notification_requests.dismiss": "Dismiss",
|
||||
"notification_requests.dismiss_all": "Dismiss all",
|
||||
"notification_requests.dismiss_multiple": "{count, plural, one {Dismiss # request} other {Dismiss # requests}}",
|
||||
"notification_requests.enter_selection_mode": "Select",
|
||||
"notification_requests.exit_selection_mode": "Cancel",
|
||||
"notification_requests.explainer_for_limited_account": "Notifications from this account have been filtered because the account has been limited by a moderator.",
|
||||
"notification_requests.explainer_for_limited_remote_account": "Notifications from this account have been filtered because the account or its server has been limited by a moderator.",
|
||||
"notification_requests.maximize": "Maximize",
|
||||
"notification_requests.minimize_banner": "Minimize filtred notifications banner",
|
||||
"notification_requests.minimize_banner": "Minimize filtered notifications banner",
|
||||
"notification_requests.notifications_from": "Notifications from {name}",
|
||||
"notification_requests.title": "Filtered notifications",
|
||||
"notification_requests.view": "View notifications",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||
"notifications.clear_title": "Clear notifications?",
|
||||
|
@ -545,6 +574,14 @@
|
|||
"notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
|
||||
"notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
|
||||
"notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",
|
||||
"notifications.policy.accept": "Accept",
|
||||
"notifications.policy.accept_hint": "Show in notifications",
|
||||
"notifications.policy.drop": "Ignore",
|
||||
"notifications.policy.drop_hint": "Send to the void, never to be seen again",
|
||||
"notifications.policy.filter": "Filter",
|
||||
"notifications.policy.filter_hint": "Send to filtered notifications inbox",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Limited by server moderators",
|
||||
"notifications.policy.filter_limited_accounts_title": "Moderated accounts",
|
||||
"notifications.policy.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}",
|
||||
"notifications.policy.filter_new_accounts_title": "New accounts",
|
||||
"notifications.policy.filter_not_followers_hint": "Including people who have been following you fewer than {days, plural, one {one day} other {# days}}",
|
||||
|
@ -553,7 +590,7 @@
|
|||
"notifications.policy.filter_not_following_title": "People you don't follow",
|
||||
"notifications.policy.filter_private_mentions_hint": "Filtered unless it's in reply to your own mention or if you follow the sender",
|
||||
"notifications.policy.filter_private_mentions_title": "Unsolicited private mentions",
|
||||
"notifications.policy.title": "Filter out notifications from…",
|
||||
"notifications.policy.title": "Manage notifications from…",
|
||||
"notifications_permission_banner.enable": "Enable desktop notifications",
|
||||
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
|
||||
"notifications_permission_banner.title": "Never miss a thing",
|
||||
|
@ -794,6 +831,7 @@
|
|||
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
|
||||
"timeline_hint.resources.followers": "Followers",
|
||||
"timeline_hint.resources.follows": "Follows",
|
||||
"timeline_hint.resources.replies": "Some replies",
|
||||
"timeline_hint.resources.statuses": "Older posts",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
|
||||
"trends.trending_now": "Trending now",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Ĉi tiu informo ne estas disponebla ĉe ĉi tiu servilo.",
|
||||
"about.powered_by": "Malcentrigita socia retejo pere de {mastodon}",
|
||||
"about.rules": "Regularo de la servilo",
|
||||
"account.account_note_header": "Noto",
|
||||
"account.add_or_remove_from_list": "Aldoni al aŭ forigi el listoj",
|
||||
"account.badges.bot": "Roboto",
|
||||
"account.badges.group": "Grupo",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Esta información no está disponible en este servidor.",
|
||||
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
||||
"about.rules": "Reglas del servidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.account_note_header": "Nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.group": "Grupo",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} editó un mensaje",
|
||||
"notification_requests.accept": "Aceptar",
|
||||
"notification_requests.dismiss": "Descartar",
|
||||
"notification_requests.maximize": "Maximizar",
|
||||
"notification_requests.minimize_banner": "Minimizar la barra de notificaciones filtradas",
|
||||
"notification_requests.notifications_from": "Notificaciones de {name}",
|
||||
"notification_requests.title": "Notificaciones filtradas",
|
||||
"notifications.clear": "Limpiar notificaciones",
|
||||
|
@ -543,6 +545,8 @@
|
|||
"notifications.permission_denied": "Las notificaciones de escritorio no están disponibles, debido a una solicitud de permiso del navegador web previamente denegada",
|
||||
"notifications.permission_denied_alert": "No se pueden habilitar las notificaciones de escritorio, ya que el permiso del navegador fue denegado antes",
|
||||
"notifications.permission_required": "Las notificaciones de escritorio no están disponibles porque no se concedió el permiso requerido.",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Limitada por los moderadores del servidor",
|
||||
"notifications.policy.filter_limited_accounts_title": "Cuentas moderadas",
|
||||
"notifications.policy.filter_new_accounts.hint": "Creada hace {days, plural, one {un día} other {# días}}",
|
||||
"notifications.policy.filter_new_accounts_title": "Nuevas cuentas",
|
||||
"notifications.policy.filter_not_followers_hint": "Incluyendo cuentas que te han estado siguiendo menos de {days, plural, one {un día} other {# días}}",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Esta información no está disponible en este servidor.",
|
||||
"about.powered_by": "Medio social descentralizado con tecnología de {mastodon}",
|
||||
"about.rules": "Reglas del servidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.account_note_header": "Nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o eliminar de las listas",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Grupo",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} editó una publicación",
|
||||
"notification_requests.accept": "Aceptar",
|
||||
"notification_requests.dismiss": "Descartar",
|
||||
"notification_requests.maximize": "Maximizar",
|
||||
"notification_requests.minimize_banner": "Minimizar banner de notificaciones filtradas",
|
||||
"notification_requests.notifications_from": "Notificaciones de {name}",
|
||||
"notification_requests.title": "Notificaciones filtradas",
|
||||
"notifications.clear": "Limpiar notificaciones",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Esta información no está disponible en este servidor.",
|
||||
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
||||
"about.rules": "Reglas del servidor",
|
||||
"account.account_note_header": "Nota",
|
||||
"account.account_note_header": "Nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o eliminar de listas",
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.group": "Grupo",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} editó una publicación",
|
||||
"notification_requests.accept": "Aceptar",
|
||||
"notification_requests.dismiss": "Descartar",
|
||||
"notification_requests.maximize": "Maximizar",
|
||||
"notification_requests.minimize_banner": "Minimizar banner de notificaciones filtradas",
|
||||
"notification_requests.notifications_from": "Notificaciones de {name}",
|
||||
"notification_requests.title": "Notificaciones filtradas",
|
||||
"notifications.clear": "Limpiar notificaciones",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "See info ei ole sellel serveril saadavaks tehtud.",
|
||||
"about.powered_by": "Hajutatud sotsiaalmeedia, mille taga on {mastodon}",
|
||||
"about.rules": "Serveri reeglid",
|
||||
"account.account_note_header": "Märge",
|
||||
"account.add_or_remove_from_list": "Lisa või Eemalda nimekirjadest",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.group": "Grupp",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Zerbitzari honek ez du informazio hau eskuragarri jarri.",
|
||||
"about.powered_by": "{mastodon} erabiltzen duen sare sozial deszentralizatua",
|
||||
"about.rules": "Zerbitzariaren arauak",
|
||||
"account.account_note_header": "Oharra",
|
||||
"account.add_or_remove_from_list": "Gehitu edo kendu zerrendetatik",
|
||||
"account.badges.bot": "Bot-a",
|
||||
"account.badges.group": "Taldea",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "این اطّلاعات روی این کارساز موجود نشده.",
|
||||
"about.powered_by": "رسانهٔ اجتماعی نامتمرکز قدرت گرفته از {mastodon}",
|
||||
"about.rules": "قوانین کارساز",
|
||||
"account.account_note_header": "یادداشت",
|
||||
"account.add_or_remove_from_list": "افزودن یا برداشتن از سیاههها",
|
||||
"account.badges.bot": "خودکار",
|
||||
"account.badges.group": "گروه",
|
||||
|
@ -86,6 +85,10 @@
|
|||
"announcement.announcement": "اعلامیه",
|
||||
"attachments_list.unprocessed": "(پردازش نشده)",
|
||||
"audio.hide": "نهفتن صدا",
|
||||
"block_modal.show_less": "نمایش کمتر",
|
||||
"block_modal.show_more": "نمایش بیشتر",
|
||||
"block_modal.title": "انسداد کاربر؟",
|
||||
"block_modal.you_wont_see_mentions": "فرستههایی که از اون نام برده را نخواهید دید.",
|
||||
"boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
|
||||
"bundle_column_error.copy_stacktrace": "رونوشت از گزارش خطا",
|
||||
"bundle_column_error.error.body": "صفحهٔ درخواستی نتوانست پرداخت شود. ممکن است به خاطر اشکالی در کدمان یا مشکل سازگاری مرورگر باشد.",
|
||||
|
@ -160,21 +163,28 @@
|
|||
"confirmations.block.confirm": "انسداد",
|
||||
"confirmations.delete.confirm": "حذف",
|
||||
"confirmations.delete.message": "آیا مطمئنید که میخواهید این فرسته را حذف کنید؟",
|
||||
"confirmations.delete.title": "حذف فرسته؟",
|
||||
"confirmations.delete_list.confirm": "حذف",
|
||||
"confirmations.delete_list.message": "مطمئنید میخواهید این سیاهه را برای همیشه حذف کنید؟",
|
||||
"confirmations.delete_list.title": "حذف سیاهه؟",
|
||||
"confirmations.discard_edit_media.confirm": "دور انداختن",
|
||||
"confirmations.discard_edit_media.message": "تغییرات ذخیره نشدهای در توضیحات یا پیشنمایش رسانه دارید. همگی نادیده گرفته شوند؟",
|
||||
"confirmations.edit.confirm": "ویرایش",
|
||||
"confirmations.edit.message": "در صورت ویرایش، پیامی که در حال نوشتنش بودید از بین خواهد رفت. میخواهید ادامه دهید؟",
|
||||
"confirmations.edit.title": "رونویسی فرسته؟",
|
||||
"confirmations.logout.confirm": "خروج از حساب",
|
||||
"confirmations.logout.message": "مطمئنید میخواهید خارج شوید؟",
|
||||
"confirmations.logout.title": "خروج؟",
|
||||
"confirmations.mute.confirm": "خموش",
|
||||
"confirmations.redraft.confirm": "حذف و بازنویسی",
|
||||
"confirmations.redraft.message": "مطمئنید که میخواهید این فرسته را حذف کنید و از نو بنویسید؟ با این کار تقویتها و پسندهایش از دست رفته و پاسخها به آن بیمرجع میشود.",
|
||||
"confirmations.redraft.title": "حذف و پیشنویسی دوبارهٔ فرسته؟",
|
||||
"confirmations.reply.confirm": "پاسخ",
|
||||
"confirmations.reply.message": "اگر الان پاسخ دهید، چیزی که در حال نوشتنش بودید پاک خواهد شد. میخواهید ادامه دهید؟",
|
||||
"confirmations.reply.title": "رونویسی فرسته؟",
|
||||
"confirmations.unfollow.confirm": "پینگرفتن",
|
||||
"confirmations.unfollow.message": "مطمئنید که میخواهید به پیگیری از {name} پایان دهید؟",
|
||||
"confirmations.unfollow.title": "ناپیگیری کاربر؟",
|
||||
"conversation.delete": "حذف گفتگو",
|
||||
"conversation.mark_as_read": "علامتگذاری به عنوان خوانده شده",
|
||||
"conversation.open": "دیدن گفتگو",
|
||||
|
@ -194,6 +204,10 @@
|
|||
"dismissable_banner.explore_statuses": "هماکنون این فرستهها از این کارساز و دیگر کارسازهای شبکهٔ نامتمرکز داغ شدهاند.",
|
||||
"dismissable_banner.explore_tags": "هماکنون این برچسبها بین افراد این کارساز و دیگر کارسازهای شبکهٔ نامتمرکز داغ شدهاند.",
|
||||
"dismissable_banner.public_timeline": "اینها جدیدترین فرستههای عمومی از افرادی روی وب اجتماعیند که اعضای {domain} پی میگیرندشان.",
|
||||
"domain_block_modal.block": "انسداد کارساز",
|
||||
"domain_block_modal.title": "انسداد دامنه؟",
|
||||
"domain_pill.server": "کارساز",
|
||||
"domain_pill.username": "نام کاربری",
|
||||
"embed.instructions": "جاسازی این فرسته روی پایگاهتان با رونوشت کردن کد زیر.",
|
||||
"embed.preview": "این گونه دیده خواهد شد:",
|
||||
"emoji_button.activity": "فعالیت",
|
||||
|
@ -388,6 +402,8 @@
|
|||
"loading_indicator.label": "در حال بارگذاری…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {نهفتن تصویر} other {نهفتن تصاویر}}",
|
||||
"moved_to_account_banner.text": "حسابتان {disabledAccount} اکنون از کار افتاده؛ چرا که به {movedToAccount} منتقل شدید.",
|
||||
"mute_modal.show_options": "نمایش گزینهها",
|
||||
"mute_modal.title": "خموشی کاربر؟",
|
||||
"navigation_bar.about": "درباره",
|
||||
"navigation_bar.advanced_interface": "بازکردن در رابط کاربری وب پیشرفته",
|
||||
"navigation_bar.blocks": "کاربران مسدود شده",
|
||||
|
@ -420,15 +436,21 @@
|
|||
"notification.follow": "{name} پیگیرتان شد",
|
||||
"notification.follow_request": "{name} درخواست پیگیریتان را داد",
|
||||
"notification.mention": "{name} به شما اشاره کرد",
|
||||
"notification.moderation-warning.learn_more": "بیشتر بدانید",
|
||||
"notification.own_poll": "نظرسنجیتان پایان یافت",
|
||||
"notification.reblog": "{name} فرستهتان را تقویت کرد",
|
||||
"notification.relationships_severance_event.learn_more": "بیشتر بدانید",
|
||||
"notification.status": "{name} چیزی فرستاد",
|
||||
"notification.update": "{name} فرستهای را ویرایش کرد",
|
||||
"notification_requests.accept": "پذیرش",
|
||||
"notification_requests.dismiss": "دورانداختن",
|
||||
"notification_requests.maximize": "بیشنه",
|
||||
"notifications.clear": "پاکسازی آگاهیها",
|
||||
"notifications.clear_confirmation": "مطمئنید میخواهید همهٔ آگاهیهایتان را برای همیشه پاک کنید؟",
|
||||
"notifications.column_settings.admin.report": "گزارشهای جدید:",
|
||||
"notifications.column_settings.admin.sign_up": "ثبت نامهای جدید:",
|
||||
"notifications.column_settings.alert": "آگاهیهای میزکار",
|
||||
"notifications.column_settings.beta.category": "ویژگیهای آزمایشی",
|
||||
"notifications.column_settings.favourite": "برگزیدهها:",
|
||||
"notifications.column_settings.follow": "پیگیرندگان جدید:",
|
||||
"notifications.column_settings.follow_request": "درخواستهای جدید پیگیری:",
|
||||
|
@ -583,8 +605,11 @@
|
|||
"report.unfollow_explanation": "شما این حساب را پیگرفتهاید، برای اینکه دیگر فرستههایش را در خوراک خانهتان نبینید؛ آن را پینگیرید.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} فرسته} other {{count} فرسته}} پیوست شده",
|
||||
"report_notification.categories.legal": "قانونی",
|
||||
"report_notification.categories.legal_sentence": "محتوای غیرقانونی",
|
||||
"report_notification.categories.other": "دیگر",
|
||||
"report_notification.categories.other_sentence": "دیگر",
|
||||
"report_notification.categories.spam": "هرزنامه",
|
||||
"report_notification.categories.spam_sentence": "هرزنامه",
|
||||
"report_notification.categories.violation": "تخطّی از قانون",
|
||||
"report_notification.open": "گشودن گزارش",
|
||||
"search.no_recent_searches": "جستوجوی اخیری نیست",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"about.contact": "Yhteydenotto:",
|
||||
"about.disclaimer": "Mastodon on vapaa avoimen lähdekoodin ohjelmisto ja Mastodon gGmbH:n tavaramerkki.",
|
||||
"about.domain_blocks.no_reason_available": "Syy ei ole tiedossa",
|
||||
"about.domain_blocks.preamble": "Mastodonin avulla voidaan yleensä tarkastella minkä tahansa fediversumiin kuuluvan palvelimen sisältöä, ja olla yhteyksissä eri palvelinten käyttäjien kanssa. Nämä poikkeukset koskevat yksin tätä palvelinta.",
|
||||
"about.domain_blocks.preamble": "Mastodonin avulla voi yleensä tarkastella minkä tahansa fediversumiin kuuluvan palvelimen sisältöä ja olla yhteyksissä eri palvelinten käyttäjien kanssa. Nämä poikkeukset koskevat yksin tätä palvelinta.",
|
||||
"about.domain_blocks.silenced.explanation": "Et yleensä näe tämän palvelimen profiileja ja sisältöä, jollet erityisesti etsi juuri sitä tai liity siihen seuraamalla.",
|
||||
"about.domain_blocks.silenced.title": "Rajoitettu",
|
||||
"about.domain_blocks.suspended.explanation": "Mitään tämän palvelimen tietoja ei käsitellä, tallenneta eikä vaihdeta, mikä tekee vuorovaikutuksesta ja viestinnästä sen käyttäjien kanssa mahdotonta.",
|
||||
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Näitä tietoja ei ole julkaistu tällä palvelimella.",
|
||||
"about.powered_by": "Hajautetun sosiaalisen median tarjoaa {mastodon}",
|
||||
"about.rules": "Palvelimen säännöt",
|
||||
"account.account_note_header": "Muistiinpano",
|
||||
"account.account_note_header": "Henkilökohtainen muistiinpano",
|
||||
"account.add_or_remove_from_list": "Lisää tai poista listoilta",
|
||||
"account.badges.bot": "Botti",
|
||||
"account.badges.group": "Ryhmä",
|
||||
|
@ -30,7 +30,7 @@
|
|||
"account.endorse": "Suosittele profiilissasi",
|
||||
"account.featured_tags.last_status_at": "Viimeisin julkaisu {date}",
|
||||
"account.featured_tags.last_status_never": "Ei julkaisuja",
|
||||
"account.featured_tags.title": "Käyttäjän {name} esille nostamat aihetunnisteet",
|
||||
"account.featured_tags.title": "Käyttäjän {name} suosittelemat aihetunnisteet",
|
||||
"account.follow": "Seuraa",
|
||||
"account.follow_back": "Seuraa takaisin",
|
||||
"account.followers": "Seuraajat",
|
||||
|
@ -143,7 +143,7 @@
|
|||
"community.column_settings.media_only": "Vain media",
|
||||
"community.column_settings.remote_only": "Vain etätilit",
|
||||
"compose.language.change": "Vaihda kieli",
|
||||
"compose.language.search": "Hae kieliä...",
|
||||
"compose.language.search": "Hae kieliä…",
|
||||
"compose.published.body": "Julkaisu lähetetty.",
|
||||
"compose.published.open": "Avaa",
|
||||
"compose.saved.body": "Julkaisu tallennettu.",
|
||||
|
@ -228,8 +228,8 @@
|
|||
"domain_pill.their_username": "Hänen yksilöllinen tunnisteensa omalla palvelimellaan. Eri palvelimilta on mahdollista löytää käyttäjiä, joilla on sama käyttäjänimi.",
|
||||
"domain_pill.username": "Käyttäjänimi",
|
||||
"domain_pill.whats_in_a_handle": "Mitä käyttäjätunnuksessa on?",
|
||||
"domain_pill.who_they_are": "Koska käyttäjätunnukset kertovat, kuka ja missä joku on, voit olla vuorovaikutuksessa ihmisten kanssa kaikkialla sosiaalisessa verkossa, joka koostuu <button>ActivityPub-pohjaisista alustoista</button>.",
|
||||
"domain_pill.who_you_are": "Koska käyttäjätunnuksesi kertoo, kuka ja missä olet, ihmiset voivat olla vaikutuksessa kanssasi kaikkialla sosiaalisessa verkossa, joka koostuu <button>ActivityPub-pohjaisista alustoista</button>.",
|
||||
"domain_pill.who_they_are": "Koska käyttäjätunnukset kertovat, kuka ja missä joku on, voit olla vuorovaikutuksessa käyttäjien kanssa kaikkialla sosiaalisessa verkossa, joka koostuu <button>ActivityPub-pohjaisista alustoista</button>.",
|
||||
"domain_pill.who_you_are": "Koska käyttäjätunnuksesi kertoo, kuka ja missä olet, käyttäjät voivat olla vaikutuksessa kanssasi kaikkialla sosiaalisessa verkossa, joka koostuu <button>ActivityPub-pohjaisista alustoista</button>.",
|
||||
"domain_pill.your_handle": "Käyttäjätunnuksesi:",
|
||||
"domain_pill.your_server": "Digitaalinen kotisi, jossa kaikki julkaisusi sijaitsevat. Etkö pidä tästä? Siirry palvelimelta toiselle milloin tahansa ja tuo myös seuraajasi mukanasi.",
|
||||
"domain_pill.your_username": "Yksilöllinen tunnisteesi tällä palvelimella. Eri palvelimilta on mahdollista löytää käyttäjiä, joilla on sama käyttäjänimi.",
|
||||
|
@ -246,7 +246,7 @@
|
|||
"emoji_button.objects": "Esineet",
|
||||
"emoji_button.people": "Ihmiset",
|
||||
"emoji_button.recent": "Usein käytetyt",
|
||||
"emoji_button.search": "Hae...",
|
||||
"emoji_button.search": "Hae…",
|
||||
"emoji_button.search_results": "Hakutulokset",
|
||||
"emoji_button.symbols": "Symbolit",
|
||||
"emoji_button.travel": "Matkailu ja paikat",
|
||||
|
@ -279,7 +279,7 @@
|
|||
"errors.unexpected_crash.copy_stacktrace": "Kopioi pinon jäljitys leikepöydälle",
|
||||
"errors.unexpected_crash.report_issue": "Ilmoita ongelmasta",
|
||||
"explore.search_results": "Hakutulokset",
|
||||
"explore.suggested_follows": "Henkilöt",
|
||||
"explore.suggested_follows": "Käyttäjät",
|
||||
"explore.title": "Selaa",
|
||||
"explore.trending_links": "Uutiset",
|
||||
"explore.trending_statuses": "Julkaisut",
|
||||
|
@ -381,7 +381,7 @@
|
|||
"keyboard_shortcuts.compose": "Kohdista kirjoituskenttään",
|
||||
"keyboard_shortcuts.description": "Kuvaus",
|
||||
"keyboard_shortcuts.direct": "Avaa yksityismainintojen sarake",
|
||||
"keyboard_shortcuts.down": "Siirry listassa alaspäin",
|
||||
"keyboard_shortcuts.down": "Siirry luettelossa eteenpäin",
|
||||
"keyboard_shortcuts.enter": "Avaa julkaisu",
|
||||
"keyboard_shortcuts.favourite": "Lisää julkaisu suosikkeihin",
|
||||
"keyboard_shortcuts.favourites": "Avaa suosikkiluettelo",
|
||||
|
@ -401,13 +401,13 @@
|
|||
"keyboard_shortcuts.reply": "Vastaa julkaisuun",
|
||||
"keyboard_shortcuts.requests": "Avaa seurantapyyntöjen luettelo",
|
||||
"keyboard_shortcuts.search": "Kohdista hakukenttään",
|
||||
"keyboard_shortcuts.spoilers": "Näytä/piilota sisältövaroituskenttä",
|
||||
"keyboard_shortcuts.spoilers": "Näytä tai piilota sisältövaroituskenttä",
|
||||
"keyboard_shortcuts.start": "Avaa Näin pääset alkuun -sarake",
|
||||
"keyboard_shortcuts.toggle_hidden": "Näytä/piilota sisältövaroituksella merkitty teksti",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "Näytä/piilota media",
|
||||
"keyboard_shortcuts.toggle_hidden": "Näytä tai piilota sisältövaroituksella merkitty teksti",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "Näytä tai piilota media",
|
||||
"keyboard_shortcuts.toot": "Luo uusi julkaisu",
|
||||
"keyboard_shortcuts.unfocus": "Poistu teksti-/hakukentästä",
|
||||
"keyboard_shortcuts.up": "Siirry listassa ylöspäin",
|
||||
"keyboard_shortcuts.unfocus": "Poistu kirjoitus- tai hakukentästä",
|
||||
"keyboard_shortcuts.up": "Siirry luettelossa taaksepäin",
|
||||
"lightbox.close": "Sulje",
|
||||
"lightbox.compress": "Tiivis kuvankatselunäkymä",
|
||||
"lightbox.expand": "Laajennettu kuvankatselunäkymä",
|
||||
|
@ -415,7 +415,7 @@
|
|||
"lightbox.previous": "Edellinen",
|
||||
"limited_account_hint.action": "Näytä profiili joka tapauksessa",
|
||||
"limited_account_hint.title": "Palvelimen {domain} moderaattorit ovat piilottaneet tämän profiilin.",
|
||||
"link_preview.author": "Julkaissut {name}",
|
||||
"link_preview.author": "Tehnyt {name}",
|
||||
"link_preview.more_from_author": "Lisää tekijältä {name}",
|
||||
"link_preview.shares": "{count, plural, one {{counter} julkaisu} other {{counter} julkaisua}}",
|
||||
"lists.account.add": "Lisää listalle",
|
||||
|
@ -453,7 +453,7 @@
|
|||
"navigation_bar.bookmarks": "Kirjanmerkit",
|
||||
"navigation_bar.community_timeline": "Paikallinen aikajana",
|
||||
"navigation_bar.compose": "Luo uusi julkaisu",
|
||||
"navigation_bar.direct": "Yksityiset maininnat",
|
||||
"navigation_bar.direct": "Yksityismaininnat",
|
||||
"navigation_bar.discover": "Löydä uutta",
|
||||
"navigation_bar.domain_blocks": "Estetyt verkkotunnukset",
|
||||
"navigation_bar.explore": "Selaa",
|
||||
|
@ -505,6 +505,8 @@
|
|||
"notification.update": "{name} muokkasi julkaisua",
|
||||
"notification_requests.accept": "Hyväksy",
|
||||
"notification_requests.dismiss": "Hylkää",
|
||||
"notification_requests.maximize": "Suurenna",
|
||||
"notification_requests.minimize_banner": "Pienennä suodatettujen ilmoitusten palkki",
|
||||
"notification_requests.notifications_from": "Ilmoitukset käyttäjältä {name}",
|
||||
"notification_requests.title": "Suodatetut ilmoitukset",
|
||||
"notifications.clear": "Tyhjennä ilmoitukset",
|
||||
|
@ -543,6 +545,8 @@
|
|||
"notifications.permission_denied": "Työpöytäilmoitukset eivät ole käytettävissä, koska selaimen käyttöoikeuspyyntö on aiemmin evätty",
|
||||
"notifications.permission_denied_alert": "Työpöytäilmoituksia ei voi ottaa käyttöön, koska selaimen käyttöoikeus on aiemmin evätty",
|
||||
"notifications.permission_required": "Työpöytäilmoitukset eivät ole käytettävissä, koska siihen tarvittavaa käyttöoikeutta ei ole myönnetty.",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Palvelimen moderaattorien rajoittamat",
|
||||
"notifications.policy.filter_limited_accounts_title": "Moderoidut tilit",
|
||||
"notifications.policy.filter_new_accounts.hint": "Luotu {days, plural, one {viime päivän} other {viimeisen # päivän}} aikana",
|
||||
"notifications.policy.filter_new_accounts_title": "Uudet tilit",
|
||||
"notifications.policy.filter_not_followers_hint": "Mukaan lukien alle {days, plural, one {päivän} other {# päivää}} sinua seuranneet",
|
||||
|
@ -581,7 +585,7 @@
|
|||
"onboarding.start.lead": "Uusi Mastodon-tilisi on nyt valmiina käyttöön. Kyseessä on ainutlaatuinen, hajautettu sosiaalisen median alusta, jolla sinä itse – algoritmin sijaan – määrität käyttökokemuksesi. Näin hyödyt Mastodonista eniten:",
|
||||
"onboarding.start.skip": "Haluatko hypätä suoraan eteenpäin ilman alkuunpääsyohjeistuksia?",
|
||||
"onboarding.start.title": "Olet tehnyt sen!",
|
||||
"onboarding.steps.follow_people.body": "Mastodon perustuu sinua kiinnostavien henkilöjen julkaisujen seuraamiseen.",
|
||||
"onboarding.steps.follow_people.body": "Mastodonissa on kyse kiinnostavien käyttäjien seuraamisesta.",
|
||||
"onboarding.steps.follow_people.title": "Mukauta kotisyötettäsi",
|
||||
"onboarding.steps.publish_status.body": "Tervehdi maailmaa sanoin, kuvin tai äänestyksin {emoji}",
|
||||
"onboarding.steps.publish_status.title": "Laadi ensimmäinen julkaisusi",
|
||||
|
@ -596,10 +600,10 @@
|
|||
"password_confirmation.exceeds_maxlength": "Salasanan vahvistus ylittää salasanan enimmäispituuden",
|
||||
"password_confirmation.mismatching": "Salasanan vahvistus ei täsmää",
|
||||
"picture_in_picture.restore": "Laita se takaisin",
|
||||
"poll.closed": "Suljettu",
|
||||
"poll.closed": "Päättynyt",
|
||||
"poll.refresh": "Päivitä",
|
||||
"poll.reveal": "Näytä tulokset",
|
||||
"poll.total_people": "{count, plural, one {# henkilö} other {# henkilöä}}",
|
||||
"poll.total_people": "{count, plural, one {# käyttäjä} other {# käyttäjää}}",
|
||||
"poll.total_votes": "{count, plural, one {# ääni} other {# ääntä}}",
|
||||
"poll.vote": "Äänestä",
|
||||
"poll.voted": "Äänestit tätä vastausta",
|
||||
|
@ -608,7 +612,7 @@
|
|||
"poll_button.remove_poll": "Poista äänestys",
|
||||
"privacy.change": "Muuta julkaisun näkyvyyttä",
|
||||
"privacy.direct.long": "Kaikki tässä julkaisussa mainitut",
|
||||
"privacy.direct.short": "Tietyt henkilöt",
|
||||
"privacy.direct.short": "Tietyt käyttäjät",
|
||||
"privacy.private.long": "Vain seuraajasi",
|
||||
"privacy.private.short": "Seuraajat",
|
||||
"privacy.public.long": "Kuka tahansa Mastodonissa ja sen ulkopuolella",
|
||||
|
@ -730,7 +734,7 @@
|
|||
"status.delete": "Poista",
|
||||
"status.detailed_status": "Yksityiskohtainen keskustelunäkymä",
|
||||
"status.direct": "Mainitse @{name} yksityisesti",
|
||||
"status.direct_indicator": "Yksityinen maininta",
|
||||
"status.direct_indicator": "Yksityismaininta",
|
||||
"status.edit": "Muokkaa",
|
||||
"status.edited": "Viimeksi muokattu {date}",
|
||||
"status.edited_x_times": "Muokattu {count, plural, one {{count} kerran} other {{count} kertaa}}",
|
||||
|
@ -781,7 +785,7 @@
|
|||
"status.unpin": "Irrota profiilista",
|
||||
"subscribed_languages.lead": "Vain valituilla kielillä kirjoitetut julkaisut näkyvät koti- ja lista-aikajanoillasi muutoksen jälkeen. Älä valitse mitään, jos haluat nähdä julkaisuja kaikilla kielillä.",
|
||||
"subscribed_languages.save": "Tallenna muutokset",
|
||||
"subscribed_languages.target": "Vaihda tilatut kielet {target}",
|
||||
"subscribed_languages.target": "Vaihda tilattuja kieliä käyttäjältä {target}",
|
||||
"tabs_bar.home": "Koti",
|
||||
"tabs_bar.notifications": "Ilmoitukset",
|
||||
"time_remaining.days": "{number, plural, one {# päivä} other {# päivää}} jäljellä",
|
||||
|
@ -793,7 +797,7 @@
|
|||
"timeline_hint.resources.followers": "seuraajat",
|
||||
"timeline_hint.resources.follows": "seuratut",
|
||||
"timeline_hint.resources.statuses": "vanhemmat julkaisut",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} henkilö} other {{counter} henkilöä}} {days, plural, one {viime päivänä} other {viimeisenä {days} päivänä}}",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} käyttäjä} other {{counter} käyttäjää}} {days, plural, one {viime päivänä} other {viimeisenä {days} päivänä}}",
|
||||
"trends.trending_now": "Suosittua nyt",
|
||||
"ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.",
|
||||
"units.short.billion": "{count} mrd.",
|
||||
|
@ -818,7 +822,7 @@
|
|||
"upload_modal.hint": "Napsauta tai vedä ympyrä esikatselussa valitaksesi keskipiste, joka näkyy aina pienoiskuvissa.",
|
||||
"upload_modal.preparing_ocr": "Valmistellaan tekstintunnistusta…",
|
||||
"upload_modal.preview_label": "Esikatselu ({ratio})",
|
||||
"upload_progress.label": "Lähetetään...",
|
||||
"upload_progress.label": "Lähetetään…",
|
||||
"upload_progress.processing": "Käsitellään…",
|
||||
"username.taken": "Tämä käyttäjänimi on jo käytössä. Kokeile toista",
|
||||
"video.close": "Sulje video",
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
"about.domain_blocks.silenced.title": "Limitado",
|
||||
"about.domain_blocks.suspended.title": "Suspendido",
|
||||
"about.rules": "Mga alituntunin ng server",
|
||||
"account.account_note_header": "Tala",
|
||||
"account.add_or_remove_from_list": "I-dagdag o tanggalin mula sa mga listahan",
|
||||
"account.badges.bot": "Pakusa",
|
||||
"account.badges.group": "Pangkat",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"about.not_available": "Hetta er ikki tøkt á føroyska servaranum enn.",
|
||||
"about.powered_by": "Miðfirra almennur miðil koyrandi á {mastodon}",
|
||||
"about.rules": "Ambætarareglur",
|
||||
"account.account_note_header": "Viðmerking",
|
||||
"account.account_note_header": "Persónlig viðmerking",
|
||||
"account.add_or_remove_from_list": "Legg afturat ella tak av listum",
|
||||
"account.badges.bot": "Bottur",
|
||||
"account.badges.group": "Bólkur",
|
||||
|
@ -72,7 +72,7 @@
|
|||
"account.unmute": "Doyv ikki @{name}",
|
||||
"account.unmute_notifications_short": "Tendra fráboðanir",
|
||||
"account.unmute_short": "Doyv ikki",
|
||||
"account_note.placeholder": "Klikka fyri at leggja notu afturat",
|
||||
"account_note.placeholder": "Klikka fyri at leggja viðmerking afturat",
|
||||
"admin.dashboard.daily_retention": "Hvussu nógvir brúkarar eru eftir, síðani tey skrásettu seg, roknað í døgum",
|
||||
"admin.dashboard.monthly_retention": "Hvussu nógvir brúkarar eru eftir síðani tey skrásettu seg, roknað í mánaðum",
|
||||
"admin.dashboard.retention.average": "Miðal",
|
||||
|
@ -505,6 +505,7 @@
|
|||
"notification.update": "{name} rættaði ein post",
|
||||
"notification_requests.accept": "Góðtak",
|
||||
"notification_requests.dismiss": "Avvís",
|
||||
"notification_requests.maximize": "Mesta",
|
||||
"notification_requests.notifications_from": "Fráboðanir frá {name}",
|
||||
"notification_requests.title": "Sáldaðar fráboðanir",
|
||||
"notifications.clear": "Rudda fráboðanir",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Cette information n'a pas été rendue disponible sur ce serveur.",
|
||||
"about.powered_by": "Réseau social décentralisé propulsé par {mastodon}",
|
||||
"about.rules": "Règles du serveur",
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Ajouter ou enlever de listes",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Groupe",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Cette information n'a pas été rendue disponible sur ce serveur.",
|
||||
"about.powered_by": "Réseau social décentralisé propulsé par {mastodon}",
|
||||
"about.rules": "Règles du serveur",
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Ajouter ou retirer des listes",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Groupe",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"about.not_available": "Dizze ynformaasje is troch dizze server net iepenbier makke.",
|
||||
"about.powered_by": "Desintralisearre sosjale media, mooglik makke troch {mastodon}",
|
||||
"about.rules": "Serverrigels",
|
||||
"account.account_note_header": "Opmerking",
|
||||
"account.add_or_remove_from_list": "Tafoegje oan of fuortsmite út listen",
|
||||
"account.badges.bot": "Automatisearre",
|
||||
"account.badges.group": "Groep",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue