commit
37b1d4a2df
293 changed files with 5290 additions and 2874 deletions
.rubocop_todo.yml.ruby-versionCHANGELOG.mdDockerfileFEDERATION.mdGemfile.lockSECURITY.md
app
controllers/api/v1
javascript/mastodon
actions
api
components
account.tsxdropdown_menu.jsxdropdown_menu.tsx
edited_timestamp
follow_button.tsxhashtag.tsxhashtag_bar.tsxicon_button.tsxnavigation_portal.tsxremote_hint.tsxstatus_action_bar.jsxstatus_content.jsxcontainers
features
account
account_featured
account_gallery
account_timeline
alt_text_modal
bookmarked_statuses
compose/components
direct_timeline/components
explore/components
favourited_statuses
followed_tags
hashtag_timeline/components
lists
notifications
status/components
ui
hooks
locales
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.75.1.
|
||||
# using RuboCop version 1.75.2.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -58,12 +58,6 @@ Style/FormatStringToken:
|
|||
Style/GuardClause:
|
||||
Enabled: false
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
Style/HashTransformValues:
|
||||
Exclude:
|
||||
- 'app/serializers/rest/web_push_subscription_serializer.rb'
|
||||
- 'app/services/import_service.rb'
|
||||
|
||||
# Configuration parameters: AllowedMethods.
|
||||
# AllowedMethods: respond_to_missing?
|
||||
Style/OptionalBooleanParameter:
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.4.2
|
||||
3.4.3
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -2,6 +2,29 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.7] - 2025-04-02
|
||||
|
||||
### Add
|
||||
|
||||
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
|
||||
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire)
|
||||
- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski)
|
||||
- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire)
|
||||
- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire)
|
||||
- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire)
|
||||
- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire)
|
||||
- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire)
|
||||
- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire)
|
||||
- Fix “x” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire)
|
||||
|
||||
## [4.3.6] - 2025-03-13
|
||||
|
||||
### Security
|
||||
|
|
|
@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
|||
|
||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||
# renovate: datasource=docker depName=docker.io/ruby
|
||||
ARG RUBY_VERSION="3.4.2"
|
||||
ARG RUBY_VERSION="3.4.3"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="22"
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
||||
- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
|
||||
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts
|
||||
|
||||
## ActivityPub in Mastodon
|
||||
|
||||
|
|
41
Gemfile.lock
41
Gemfile.lock
|
@ -94,7 +94,7 @@ GEM
|
|||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1080.0)
|
||||
aws-partitions (1.1087.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
@ -120,13 +120,13 @@ GEM
|
|||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.1.9)
|
||||
bindata (2.5.0)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.1)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
|
@ -160,7 +160,7 @@ GEM
|
|||
cocoon (1.2.15)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
connection_pool (2.5.1)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
|
@ -170,7 +170,7 @@ GEM
|
|||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.3)
|
||||
csv (3.3.4)
|
||||
database_cleaner-active_record (2.2.0)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
|
@ -199,9 +199,9 @@ GEM
|
|||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.8.1)
|
||||
doorkeeper (5.8.2)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.7)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.1)
|
||||
elasticsearch (7.17.11)
|
||||
elasticsearch-api (= 7.17.11)
|
||||
|
@ -227,7 +227,7 @@ GEM
|
|||
fabrication (2.31.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
faraday (2.13.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
@ -239,7 +239,7 @@ GEM
|
|||
net-http (>= 0.5.0)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.0)
|
||||
ffi (1.17.1)
|
||||
ffi (1.17.2)
|
||||
ffi-compiler (1.3.2)
|
||||
ffi (>= 1.15.5)
|
||||
rake
|
||||
|
@ -280,7 +280,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.61.1)
|
||||
haml_lint (0.62.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
@ -395,7 +395,7 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.6.3)
|
||||
linzer (0.6.5)
|
||||
openssl (~> 3.0, >= 3.0.0)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
|
@ -426,7 +426,7 @@ GEM
|
|||
mime-types (3.6.2)
|
||||
logger
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2025.0402)
|
||||
mime-types-data (3.2025.0408)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
|
@ -495,6 +495,8 @@ GEM
|
|||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.1.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.4.0)
|
||||
|
@ -548,8 +550,9 @@ GEM
|
|||
opentelemetry-instrumentation-net_http (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (0.30.0)
|
||||
opentelemetry-instrumentation-pg (0.30.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (0.26.0)
|
||||
|
@ -585,8 +588,8 @@ GEM
|
|||
ostruct (0.6.1)
|
||||
ox (2.14.22)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
|
@ -697,7 +700,7 @@ GEM
|
|||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
reline (0.6.1)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
@ -751,7 +754,7 @@ GEM
|
|||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.0)
|
||||
rubocop-ast (1.44.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
|
@ -797,7 +800,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.30.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
|
@ -1085,4 +1088,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
2.6.8
|
||||
|
|
11
SECURITY.md
11
SECURITY.md
|
@ -13,9 +13,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Until 2025-04-08 |
|
||||
| < 4.1 | No |
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| < 4.2 | No |
|
||||
|
|
66
app/controllers/api/v1/accounts/endorsements_controller.rb
Normal file
66
app/controllers/api/v1/accounts/endorsements_controller.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::EndorsementsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||
before_action :require_user!, except: :index
|
||||
before_action :set_account
|
||||
before_action :set_endorsed_accounts, only: :index
|
||||
after_action :insert_pagination_headers, only: :index
|
||||
|
||||
def index
|
||||
cache_if_unauthenticated!
|
||||
render json: @endorsed_accounts, each_serializer: REST::AccountSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
def destroy
|
||||
pin = AccountPin.find_by(account: current_account, target_account: @account)
|
||||
pin&.destroy!
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_endorsed_accounts
|
||||
@endorsed_accounts = @account.unavailable? ? [] : paginated_endorsed_accounts
|
||||
end
|
||||
|
||||
def paginated_endorsed_accounts
|
||||
@account.endorsed_accounts.without_suspended.includes(:account_stat, :user).paginate_by_max_id(
|
||||
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]
|
||||
)
|
||||
end
|
||||
|
||||
def relationships_presenter
|
||||
AccountRelationshipsPresenter.new([@account], current_user.account_id)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_account_endorsements_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_account_endorsements_url pagination_params(since_id: pagination_since_id) unless @endorsed_accounts.empty?
|
||||
end
|
||||
|
||||
def pagination_collection
|
||||
@endorsed_accounts
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@endorsed_accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
end
|
||||
end
|
|
@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
|
|||
end
|
||||
|
||||
def set_featured_tags
|
||||
@featured_tags = @account.suspended? ? [] : @account.featured_tags
|
||||
@featured_tags = @account.unavailable? ? [] : @account.featured_tags
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::PinsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
before_action :set_account
|
||||
|
||||
def create
|
||||
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
def destroy
|
||||
pin = AccountPin.find_by(account: current_account, target_account: @account)
|
||||
pin&.destroy!
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def relationships_presenter
|
||||
AccountRelationshipsPresenter.new([@account], current_user.account_id)
|
||||
end
|
||||
end
|
|
@ -7,10 +7,6 @@ class Api::V1::ListsController < Api::BaseController
|
|||
before_action :require_user!
|
||||
before_action :set_list, except: [:index, :create]
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.to_s }, status: 422
|
||||
end
|
||||
|
||||
def index
|
||||
@lists = List.where(account: current_account).all
|
||||
render json: @lists, each_serializer: REST::ListSerializer
|
||||
|
|
|
@ -4,7 +4,7 @@ class Api::V1::SuggestionsController < Api::BaseController
|
|||
include Authorization
|
||||
include DeprecationConcern
|
||||
|
||||
deprecate_api '2021-05-16'
|
||||
deprecate_api '2021-05-16', only: [:index]
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiRemoveAccountFromFollowers } from 'mastodon/api/accounts';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const revealAccount = createAction<{
|
||||
id: string;
|
||||
|
@ -95,3 +97,10 @@ export const fetchRelationshipsSuccess = createAction(
|
|||
'relationships/fetch/SUCCESS',
|
||||
actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>,
|
||||
);
|
||||
|
||||
export const removeAccountFromFollowers = createDataLoadingThunk(
|
||||
'accounts/remove_from_followers',
|
||||
({ accountId }: { accountId: string }) =>
|
||||
apiRemoveAccountFromFollowers(accountId),
|
||||
(relationship) => ({ relationship }),
|
||||
);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openDropdownMenu = createAction<{
|
||||
id: string;
|
||||
id: number;
|
||||
keyboard: boolean;
|
||||
scrollKey: string;
|
||||
scrollKey?: string;
|
||||
}>('dropdownMenu/open');
|
||||
|
||||
export const closeDropdownMenu = createAction<{ id: string }>(
|
||||
export const closeDropdownMenu = createAction<{ id: number }>(
|
||||
'dropdownMenu/close',
|
||||
);
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api().get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api().get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
|
@ -18,3 +18,8 @@ export const apiFollowAccount = (
|
|||
|
||||
export const apiUnfollowAccount = (id: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/unfollow`);
|
||||
|
||||
export const apiRemoveAccountFromFollowers = (id: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(
|
||||
`v1/accounts/${id}/remove_from_followers`,
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
|
@ -9,3 +9,15 @@ export const apiFollowTag = (tagId: string) =>
|
|||
|
||||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/followed_tags',
|
||||
});
|
||||
|
||||
return {
|
||||
tags: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -12,18 +12,19 @@ import {
|
|||
muteAccount,
|
||||
unmuteAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -46,6 +47,14 @@ const messages = defineMessages({
|
|||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
addToLists: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
});
|
||||
|
||||
export const Account: React.FC<{
|
||||
|
@ -60,6 +69,7 @@ export const Account: React.FC<{
|
|||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const relationship = useAppSelector((state) => state.relationships.get(id));
|
||||
const dispatch = useAppDispatch();
|
||||
const accountUrl = account?.url;
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (relationship?.blocking) {
|
||||
|
@ -77,13 +87,62 @@ export const Account: React.FC<{
|
|||
}
|
||||
}, [dispatch, id, account, relationship]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, true));
|
||||
}, [dispatch, id]);
|
||||
const menu = useMemo(() => {
|
||||
let arr: MenuItem[] = [];
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, false));
|
||||
}, [dispatch, id]);
|
||||
if (defaultAction === 'mute') {
|
||||
const handleMuteNotifications = () => {
|
||||
dispatch(muteAccount(id, true));
|
||||
};
|
||||
|
||||
const handleUnmuteNotifications = () => {
|
||||
dispatch(muteAccount(id, false));
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship?.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship?.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
const handleAddToLists = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
},
|
||||
];
|
||||
|
||||
if (accountUrl) {
|
||||
arr.unshift(
|
||||
{
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: accountUrl,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
|
@ -94,69 +153,42 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
let button: React.ReactNode, dropdown: React.ReactNode;
|
||||
|
||||
if (account && account.id !== me && relationship) {
|
||||
const { requested, blocking, muting } = relationship;
|
||||
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
} else if (blocking) {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (muting) {
|
||||
const menu = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unmute)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = (
|
||||
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
|
||||
);
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.block)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
if (menu.length > 0) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
if (defaultAction === 'block') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.blocking ? messages.unblock : messages.block,
|
||||
)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.muting ? messages.unmute : messages.mute,
|
||||
)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
button = <FollowButton accountId={id} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining: React.ReactNode;
|
||||
|
||||
if (account?.mute_expires_at) {
|
||||
muteTimeRemaining = (
|
||||
|
@ -166,7 +198,7 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
let verification: React.ReactNode;
|
||||
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
|
||||
|
@ -212,7 +244,12 @@ export const Account: React.FC<{
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && <div className='account__relationship'>{buttons}</div>}
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{dropdown}
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account &&
|
||||
|
|
|
@ -1,343 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent, cloneElement, Children } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
let id = 0;
|
||||
|
||||
class DropdownMenu extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
scrollable: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
renderItem: PropTypes.func,
|
||||
renderHeader: PropTypes.func,
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
||||
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.querySelectorAll('a, button'));
|
||||
const index = items.indexOf(document.activeElement);
|
||||
let element = null;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index+1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index-1] || items[items.length-1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index-1] || items[items.length-1];
|
||||
} else {
|
||||
element = items[index+1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length-1];
|
||||
break;
|
||||
case 'Escape':
|
||||
this.props.onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
handleItemKeyPress = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
const { onItemClick } = this.props;
|
||||
onItemClick(e);
|
||||
};
|
||||
|
||||
renderItem = (option, i) => {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href = '#', target = '_blank', method, dangerous } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { items, scrollable, renderHeader, loading } = this.props;
|
||||
|
||||
let renderItem = this.props.renderItem || this.renderItem;
|
||||
|
||||
return (
|
||||
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
|
||||
{loading && (
|
||||
<CircularProgress size={30} strokeWidth={3.5} />
|
||||
)}
|
||||
|
||||
{!loading && renderHeader && (
|
||||
<div className='dropdown-menu__container__header'>
|
||||
{renderHeader(items)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
|
||||
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Dropdown extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
iconComponent: PropTypes.func,
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
title: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
scrollable: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
openDropdownId: PropTypes.number,
|
||||
openedViaKeyboard: PropTypes.bool,
|
||||
renderItem: PropTypes.func,
|
||||
renderHeader: PropTypes.func,
|
||||
onItemClick: PropTypes.func,
|
||||
...WithRouterPropTypes
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
};
|
||||
|
||||
handleClick = ({ type }) => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
} else {
|
||||
this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement) {
|
||||
this.activeElement.focus({ preventScroll: true });
|
||||
this.activeElement = null;
|
||||
}
|
||||
this.props.onClose(this.state.id);
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonKeyDown = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyPress = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleItemClick = e => {
|
||||
const { onItemClick } = this.props;
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (item && typeof item.action === 'function') {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
} else if (item && item.to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(item.to);
|
||||
}
|
||||
};
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
};
|
||||
|
||||
findTarget = () => {
|
||||
return this.target?.buttonRef?.current ?? this.target;
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
close = () => {
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render () {
|
||||
const {
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
size,
|
||||
title,
|
||||
disabled,
|
||||
loading,
|
||||
scrollable,
|
||||
openDropdownId,
|
||||
openedViaKeyboard,
|
||||
children,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
} = this.props;
|
||||
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
const button = children ? cloneElement(Children.only(children), {
|
||||
onClick: this.handleClick,
|
||||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
}) : (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
|
||||
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
loading={loading}
|
||||
scrollable={scrollable}
|
||||
onClose={this.handleClose}
|
||||
openedViaKeyboard={openedViaKeyboard}
|
||||
renderItem={renderItem}
|
||||
renderHeader={renderHeader}
|
||||
onItemClick={this.handleItemClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
549
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
549
app/javascript/mastodon/components/dropdown_menu.tsx
Normal file
|
@ -0,0 +1,549 @@
|
|||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
cloneElement,
|
||||
Children,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
import {
|
||||
openDropdownMenu,
|
||||
closeDropdownMenu,
|
||||
} from 'mastodon/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { isUserTouching } from 'mastodon/is_mobile';
|
||||
import type {
|
||||
MenuItem,
|
||||
ActionMenuItem,
|
||||
ExternalLinkMenuItem,
|
||||
} from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
const isMenuItem = (item: unknown): item is MenuItem => {
|
||||
if (item === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof item === 'object' && 'text' in item;
|
||||
};
|
||||
|
||||
const isActionItem = (item: unknown): item is ActionMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'action' in item;
|
||||
};
|
||||
|
||||
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'href' in item;
|
||||
};
|
||||
|
||||
type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
},
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
|
||||
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
|
||||
|
||||
interface DropdownMenuProps<Item = MenuItem> {
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
scrollable?: boolean;
|
||||
onClose: () => void;
|
||||
openedViaKeyboard: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
export const DropdownMenu = <Item = MenuItem,>({
|
||||
items,
|
||||
loading,
|
||||
scrollable,
|
||||
onClose,
|
||||
openedViaKeyboard,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
if (
|
||||
e.target instanceof Node &&
|
||||
nodeRef.current &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!nodeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(nodeRef.current.querySelectorAll('a, button'));
|
||||
const index = document.activeElement
|
||||
? items.indexOf(document.activeElement)
|
||||
: -1;
|
||||
|
||||
let element: Element | undefined;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] ?? items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] ?? items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] ?? items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] ?? items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
onClose();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
}
|
||||
},
|
||||
[onClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleItemClick(e);
|
||||
}
|
||||
},
|
||||
[handleItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, dangerous } = option;
|
||||
|
||||
let element: React.ReactElement;
|
||||
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
} else if (isExternalLinkItem(option)) {
|
||||
element = (
|
||||
<a
|
||||
href={option.href}
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item', {
|
||||
'dropdown-menu__item--dangerous': dangerous,
|
||||
})}
|
||||
key={`${text}-${i}`}
|
||||
>
|
||||
{element}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItemMethod = renderItem ?? nativeRenderItem;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('dropdown-menu__container', {
|
||||
'dropdown-menu__container--loading': loading,
|
||||
})}
|
||||
ref={nodeRef}
|
||||
>
|
||||
{(loading || !items) && <CircularProgress size={30} strokeWidth={3.5} />}
|
||||
|
||||
{!loading && renderHeader && items && (
|
||||
<div className='dropdown-menu__container__header'>
|
||||
{renderHeader(items)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items && (
|
||||
<ul
|
||||
className={classNames('dropdown-menu__container__list', {
|
||||
'dropdown-menu__container__list--scrollable': scrollable,
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(option, i, {
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
}),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps<Item = MenuItem> {
|
||||
children?: React.ReactElement;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
items?: Item[];
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onOpen?: () => void;
|
||||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const Dropdown = <Item = MenuItem,>({
|
||||
children,
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
loading,
|
||||
title = 'Menu',
|
||||
disabled,
|
||||
scrollable,
|
||||
status,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onOpen,
|
||||
onItemClick,
|
||||
scrollKey,
|
||||
}: DropdownProps<Item>) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId);
|
||||
const openedViaKeyboard = useAppSelector(
|
||||
(state) => state.dropdownMenu.keyboard,
|
||||
);
|
||||
const [currentId] = useState(id++);
|
||||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
activeElement.current.focus({ preventScroll: true });
|
||||
activeElement.current = null;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
closeModal({
|
||||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(closeDropdownMenu({ id: currentId }));
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
handleClose();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
}
|
||||
},
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
onOpen?.();
|
||||
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
if (isUserTouching()) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
currentId,
|
||||
scrollKey,
|
||||
onOpen,
|
||||
handleItemClick,
|
||||
open,
|
||||
status,
|
||||
items,
|
||||
handleClose,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!open && document.activeElement instanceof HTMLElement) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentId === openDropdownId) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
}, [currentId, openDropdownId, handleClose]);
|
||||
|
||||
let button: React.ReactElement;
|
||||
|
||||
if (children) {
|
||||
button = cloneElement(Children.only(children), {
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: targetRef,
|
||||
});
|
||||
} else if (icon && iconComponent) {
|
||||
button = (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={targetRef}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
flip
|
||||
target={targetRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
{...arrowProps}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
loading={loading}
|
||||
scrollable={scrollable}
|
||||
onClose={handleClose}
|
||||
openedViaKeyboard={openedViaKeyboard}
|
||||
renderItem={renderItem}
|
||||
renderHeader={renderHeader}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
|
||||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import DropdownMenu from 'mastodon/components/dropdown_menu';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
items: state.getIn(['history', statusId, 'items']),
|
||||
loading: state.getIn(['history', statusId, 'loading']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { statusId }) => ({
|
||||
|
||||
onOpen (id, onItemClick, keyboard) {
|
||||
dispatch(fetchHistory(statusId));
|
||||
dispatch(openDropdownMenu({ id, keyboard }));
|
||||
},
|
||||
|
||||
onClose (id) {
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -1,77 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
import DropdownMenu from './containers/dropdown_menu_container';
|
||||
|
||||
const mapDispatchToProps = (dispatch, { statusId }) => ({
|
||||
|
||||
onItemClick (index) {
|
||||
dispatch(openModal({
|
||||
modalType: 'COMPARE_HISTORY',
|
||||
modalProps: { index, statusId },
|
||||
}));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
class EditedTimestamp extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleItemClick = (item, i) => {
|
||||
const { onItemClick } = this.props;
|
||||
onItemClick(i);
|
||||
};
|
||||
|
||||
renderHeader = items => {
|
||||
return (
|
||||
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
|
||||
);
|
||||
};
|
||||
|
||||
renderItem = (item, index, { onClick, onKeyPress }) => {
|
||||
const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
|
||||
const formattedName = <InlineAccount accountId={item.get('account')} />;
|
||||
|
||||
const label = item.get('original') ? (
|
||||
<FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
|
||||
) : (
|
||||
<FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
|
||||
<button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { timestamp, statusId } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));
|
140
app/javascript/mastodon/components/edited_timestamp/index.tsx
Normal file
140
app/javascript/mastodon/components/edited_timestamp/index.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
type HistoryItem = ImmutableMap<string, unknown>;
|
||||
|
||||
export const EditedTimestamp: React.FC<{
|
||||
statusId: string;
|
||||
timestamp: string;
|
||||
}> = ({ statusId, timestamp }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(
|
||||
(state) =>
|
||||
(
|
||||
state.history.getIn([statusId, 'items']) as
|
||||
| ImmutableList<unknown>
|
||||
| undefined
|
||||
)?.toArray() as HistoryItem[],
|
||||
);
|
||||
const loading = useAppSelector(
|
||||
(state) => state.history.getIn([statusId, 'loading']) as boolean,
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
dispatch(fetchHistory(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(_item: HistoryItem, index: number) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'COMPARE_HISTORY',
|
||||
modalProps: { index, statusId },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, statusId],
|
||||
);
|
||||
|
||||
const renderHeader = useCallback((items: HistoryItem[]) => {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='status.edited_x_times'
|
||||
defaultMessage='Edited {count, plural, one {# time} other {# times}}'
|
||||
values={{ count: items.length - 1 }}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
item: HistoryItem,
|
||||
index: number,
|
||||
{
|
||||
onClick,
|
||||
onKeyUp,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
},
|
||||
) => {
|
||||
const formattedDate = (
|
||||
<RelativeTimestamp
|
||||
timestamp={item.get('created_at') as string}
|
||||
short={false}
|
||||
/>
|
||||
);
|
||||
const formattedName = (
|
||||
<InlineAccount accountId={item.get('account') as string} />
|
||||
);
|
||||
|
||||
const label = (item.get('original') as boolean) ? (
|
||||
<FormattedMessage
|
||||
id='status.history.created'
|
||||
defaultMessage='{name} created {date}'
|
||||
values={{ name: formattedName, date: formattedDate }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='status.history.edited'
|
||||
defaultMessage='{name} edited {date}'
|
||||
values={{ name: formattedName, date: formattedDate }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className='dropdown-menu__item edited-timestamp__history__item'
|
||||
key={item.get('created_at') as string}
|
||||
>
|
||||
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown<HistoryItem>
|
||||
items={items}
|
||||
loading={loading}
|
||||
renderItem={renderItem}
|
||||
scrollable
|
||||
renderHeader={renderHeader}
|
||||
onOpen={handleOpen}
|
||||
onItemClick={handleItemClick}
|
||||
>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage
|
||||
id='status.edited'
|
||||
defaultMessage='Edited {date}'
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDateWrapper
|
||||
className='animated-number'
|
||||
value={timestamp}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
|
@ -16,8 +16,7 @@ const messages = defineMessages({
|
|||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
});
|
||||
|
||||
export const FollowButton: React.FC<{
|
||||
|
@ -73,11 +72,9 @@ export const FollowButton: React.FC<{
|
|||
if (!signedIn) {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
} else if (accountId === me) {
|
||||
label = intl.formatMessage(messages.edit_profile);
|
||||
label = intl.formatMessage(messages.editProfile);
|
||||
} else if (!relationship) {
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.following && relationship.followed_by) {
|
||||
label = intl.formatMessage(messages.mutual);
|
||||
} else if (relationship.following || relationship.requested) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
} else if (relationship.followed_by) {
|
||||
|
|
|
@ -102,10 +102,11 @@ export interface HashtagProps {
|
|||
description?: React.ReactNode;
|
||||
history?: number[];
|
||||
name: string;
|
||||
people: number;
|
||||
people?: number;
|
||||
to: string;
|
||||
uses?: number;
|
||||
withGraph?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
|
@ -117,6 +118,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
className,
|
||||
description,
|
||||
withGraph = true,
|
||||
children,
|
||||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
|
@ -158,5 +160,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='trends__item__buttons'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ export type StatusLike = Record<{
|
|||
contentHTML: string;
|
||||
media_attachments: List<unknown>;
|
||||
spoiler_text?: string;
|
||||
account: Record<{ id: string }>;
|
||||
}>;
|
||||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
|
@ -195,13 +196,19 @@ export function getHashtagBarForStatus(status: StatusLike) {
|
|||
|
||||
return {
|
||||
statusContentProps,
|
||||
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
|
||||
hashtagBar: (
|
||||
<HashtagBar
|
||||
hashtags={hashtagsInBar}
|
||||
accountId={status.getIn(['account', 'id']) as string}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const HashtagBar: React.FC<{
|
||||
hashtags: string[];
|
||||
}> = ({ hashtags }) => {
|
||||
accountId: string;
|
||||
}> = ({ hashtags, accountId }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded(true);
|
||||
|
@ -218,7 +225,11 @@ const HashtagBar: React.FC<{
|
|||
return (
|
||||
<div className='hashtag-bar'>
|
||||
{revealedHashtags.map((hashtag) => (
|
||||
<Link key={hashtag} to={`/tags/${hashtag}`}>
|
||||
<Link
|
||||
key={hashtag}
|
||||
to={`/tags/${hashtag}`}
|
||||
data-menu-hashtag={accountId}
|
||||
>
|
||||
#<span>{hashtag}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { PureComponent, createRef } from 'react';
|
||||
import { useState, useEffect, useCallback, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
@ -15,99 +15,108 @@ interface Props {
|
|||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
active: boolean;
|
||||
active?: boolean;
|
||||
expanded?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
activeStyle?: React.CSSProperties;
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
inverted?: boolean;
|
||||
animate: boolean;
|
||||
overlay: boolean;
|
||||
tabIndex: number;
|
||||
animate?: boolean;
|
||||
overlay?: boolean;
|
||||
tabIndex?: number;
|
||||
counter?: number;
|
||||
href?: string;
|
||||
ariaHidden: boolean;
|
||||
ariaHidden?: boolean;
|
||||
}
|
||||
interface States {
|
||||
activate: boolean;
|
||||
deactivate: boolean;
|
||||
}
|
||||
export class IconButton extends PureComponent<Props, States> {
|
||||
buttonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
static defaultProps = {
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: 0,
|
||||
ariaHidden: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
activate: false,
|
||||
deactivate: false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
if (!nextProps.animate) return;
|
||||
|
||||
if (this.props.active && !nextProps.active) {
|
||||
this.setState({ activate: false, deactivate: true });
|
||||
} else if (!this.props.active && nextProps.active) {
|
||||
this.setState({ activate: true, deactivate: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled && this.props.onClick != null) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (this.props.onKeyPress && !this.props.disabled) {
|
||||
this.props.onKeyPress(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!this.props.disabled && this.props.onMouseDown) {
|
||||
this.props.onMouseDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!this.props.disabled && this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
...this.props.style,
|
||||
...(this.props.active ? this.props.activeStyle : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
iconComponent,
|
||||
inverted,
|
||||
overlay,
|
||||
tabIndex,
|
||||
title,
|
||||
counter,
|
||||
href,
|
||||
ariaHidden,
|
||||
} = this.props;
|
||||
style,
|
||||
activeStyle,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
active = false,
|
||||
disabled = false,
|
||||
animate = false,
|
||||
overlay = false,
|
||||
tabIndex = 0,
|
||||
ariaHidden = false,
|
||||
},
|
||||
buttonRef,
|
||||
) => {
|
||||
const [activate, setActivate] = useState(false);
|
||||
const [deactivate, setDeactivate] = useState(false);
|
||||
|
||||
const { activate, deactivate } = this.state;
|
||||
useEffect(() => {
|
||||
if (!animate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activate && !active) {
|
||||
setActivate(false);
|
||||
setDeactivate(true);
|
||||
} else if (!activate && active) {
|
||||
setActivate(true);
|
||||
setDeactivate(false);
|
||||
}
|
||||
}, [setActivate, setDeactivate, animate, active, activate]);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!disabled) {
|
||||
onClick?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
);
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyPress?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyPress],
|
||||
);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onMouseDown?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyDown],
|
||||
);
|
||||
|
||||
const buttonStyle = {
|
||||
...style,
|
||||
...(active ? activeStyle : {}),
|
||||
};
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
|
@ -146,18 +155,19 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
aria-hidden={ariaHidden}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
style={buttonStyle}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
ref={this.buttonRef}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
|
|
@ -1,25 +1,6 @@
|
|||
import { Switch, Route } from 'react-router-dom';
|
||||
|
||||
import AccountNavigation from 'mastodon/features/account/navigation';
|
||||
import Trends from 'mastodon/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
|
||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<div className='navigation-panel__portal'>
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route
|
||||
path='/@:acct/tagged/:tagged?'
|
||||
exact
|
||||
component={AccountNavigation}
|
||||
/>
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
</div>
|
||||
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
|
||||
);
|
||||
|
|
43
app/javascript/mastodon/components/remote_hint.tsx
Normal file
43
app/javascript/mastodon/components/remote_hint.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { TimelineHint } from './timeline_hint';
|
||||
|
||||
interface RemoteHintProps {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export const RemoteHint: React.FC<RemoteHintProps> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const domain = account?.acct ? account.acct.split('@')[1] : undefined;
|
||||
if (
|
||||
!account ||
|
||||
!account.url ||
|
||||
account.acct !== account.username ||
|
||||
!domain
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={account.url}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.posts_may_be_missing'
|
||||
defaultMessage='Some posts from this profile may be missing.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.see_more_posts'
|
||||
defaultMessage='See more posts on {domain}'
|
||||
values={{ domain: <strong>{domain}</strong> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -25,7 +25,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
|||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
@ -274,10 +274,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
if (writtenByMe && pinnableStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
|
||||
if (writtenByMe || withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
|
@ -390,7 +389,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
|
|
|
@ -115,6 +115,7 @@ class StatusContent extends PureComponent {
|
|||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.classList.add('unhandled-link');
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
|
||||
import { openModal, closeModal } from '../actions/modal';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
/**
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
*/
|
||||
const mapStateToProps = state => ({
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {any} dispatch
|
||||
* @param {Object} root0
|
||||
* @param {any} [root0.status]
|
||||
* @param {any} root0.items
|
||||
* @param {any} [root0.scrollKey]
|
||||
*/
|
||||
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||
onOpen(id, onItemClick, keyboard) {
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
dispatch(isUserTouching() ? openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
},
|
||||
}) : openDropdownMenu({ id, keyboard, scrollKey }));
|
||||
},
|
||||
|
||||
onClose(id) {
|
||||
dispatch(closeModal({
|
||||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}));
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -1,51 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
|
||||
const messages = defineMessages({
|
||||
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
|
||||
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
|
||||
});
|
||||
|
||||
class FeaturedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record,
|
||||
featuredTags: ImmutablePropTypes.list,
|
||||
tagged: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, featuredTags, intl } = this.props;
|
||||
|
||||
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='getting-started__trends'>
|
||||
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
|
||||
|
||||
{featuredTags.take(3).map(featuredTag => (
|
||||
<Hashtag
|
||||
key={featuredTag.get('name')}
|
||||
name={featuredTag.get('name')}
|
||||
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
|
||||
uses={featuredTag.get('statuses_count') * 1}
|
||||
withGraph={false}
|
||||
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(FeaturedTags);
|
|
@ -1,17 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
|
||||
import FeaturedTags from '../components/featured_tags';
|
||||
|
||||
const mapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
return (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
|
||||
});
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(FeaturedTags);
|
|
@ -1,52 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { match: { params: { acct } } }) => {
|
||||
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
class AccountNavigation extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
tagged: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
|
||||
accountId: PropTypes.string,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AccountNavigation);
|
|
@ -0,0 +1,69 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
||||
interface EmptyMessageProps {
|
||||
suspended: boolean;
|
||||
hidden: boolean;
|
||||
blockedBy: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
accountId,
|
||||
suspended,
|
||||
hidden,
|
||||
blockedBy,
|
||||
}) => {
|
||||
const { acct } = useParams<{ acct?: string }>();
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message: React.ReactNode = null;
|
||||
|
||||
if (me === accountId) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured.me'
|
||||
defaultMessage='You have not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?'
|
||||
/>
|
||||
);
|
||||
} else if (suspended) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
);
|
||||
} else if (hidden) {
|
||||
message = <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_unavailable'
|
||||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (acct) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured.other'
|
||||
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_other.unknown'
|
||||
defaultMessage='This account has not featured anything yet.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className='empty-column-indicator'>{message}</div>;
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
|
||||
export type TagMap = ImmutableMap<
|
||||
'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
|
||||
string | null
|
||||
>;
|
||||
|
||||
interface FeaturedTagProps {
|
||||
tag: TagMap;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
lastStatusAt: {
|
||||
id: 'account.featured_tags.last_status_at',
|
||||
defaultMessage: 'Last post on {date}',
|
||||
},
|
||||
empty: {
|
||||
id: 'account.featured_tags.last_status_never',
|
||||
defaultMessage: 'No posts',
|
||||
},
|
||||
});
|
||||
|
||||
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
|
||||
const intl = useIntl();
|
||||
const name = tag.get('name') ?? '';
|
||||
const count = Number.parseInt(tag.get('statuses_count') ?? '');
|
||||
return (
|
||||
<Hashtag
|
||||
key={name}
|
||||
name={name}
|
||||
to={`/@${account}/tagged/${name}`}
|
||||
uses={count}
|
||||
withGraph={false}
|
||||
description={
|
||||
count > 0
|
||||
? intl.formatMessage(messages.lastStatusAt, {
|
||||
date: intl.formatDate(tag.get('last_status_at') ?? '', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
}),
|
||||
})
|
||||
: intl.formatMessage(messages.empty)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
156
app/javascript/mastodon/features/account_featured/index.tsx
Normal file
156
app/javascript/mastodon/features/account_featured/index.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
|
||||
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../account_timeline/components/account_header';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { FeaturedTag } from './components/featured_tag';
|
||||
import type { TagMap } from './components/featured_tag';
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const AccountFeatured = () => {
|
||||
const accountId = useAccountId();
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
const { acct = '' } = useParams<Params>();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
void dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
dispatch(fetchFeaturedTags(accountId));
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
!accountId ||
|
||||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
|
||||
`account:${accountId}:pinned`,
|
||||
'isLoading',
|
||||
]) ||
|
||||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
|
||||
);
|
||||
const featuredTags = useAppSelector(
|
||||
(state) =>
|
||||
state.user_lists.getIn(
|
||||
['featured_tags', accountId, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<TagMap>,
|
||||
);
|
||||
const featuredStatusIds = useAppSelector(
|
||||
(state) =>
|
||||
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
||||
[`account:${accountId}:pinned`, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AccountFeaturedWrapper accountId={accountId}>
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</AccountFeaturedWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
|
||||
return (
|
||||
<AccountFeaturedWrapper accountId={accountId}>
|
||||
<EmptyMessage
|
||||
blockedBy={blockedBy}
|
||||
hidden={hidden}
|
||||
suspended={suspended}
|
||||
accountId={accountId}
|
||||
/>
|
||||
<RemoteHint accountId={accountId} />
|
||||
</AccountFeaturedWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
{accountId && (
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
)}
|
||||
{!featuredTags.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</h4>
|
||||
{featuredTags.map((tag) => (
|
||||
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!featuredStatusIds.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.posts'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</h4>
|
||||
{featuredStatusIds.map((statusId) => (
|
||||
<StatusContainer
|
||||
key={`f-${statusId}`}
|
||||
// @ts-expect-error inferred props are wrong
|
||||
id={statusId}
|
||||
contextType='account'
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountFeaturedWrapper = ({
|
||||
children,
|
||||
accountId,
|
||||
}: React.PropsWithChildren<{ accountId?: string }>) => {
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<div className='scrollable scrollable--flex'>
|
||||
{accountId && <AccountHeader accountId={accountId} />}
|
||||
{children}
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AccountFeatured;
|
|
@ -2,25 +2,22 @@ import { useEffect, useCallback } from 'react';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -56,53 +53,11 @@ const getAccountGallery = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const RemoteHint: React.FC<{
|
||||
accountId: string;
|
||||
}> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const acct = account?.acct;
|
||||
const url = account?.url;
|
||||
const domain = acct ? acct.split('@')[1] : undefined;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={url}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.posts_may_be_missing'
|
||||
defaultMessage='Some posts from this profile may be missing.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.see_more_posts'
|
||||
defaultMessage='See more posts on {domain}'
|
||||
values={{ domain: <strong>{domain}</strong> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountGallery: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const { acct, id } = useParams<Params>();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountId = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
);
|
||||
const accountId = useAccountId();
|
||||
const attachments = useAppSelector((state) =>
|
||||
accountId
|
||||
? getAccountGallery(state, accountId)
|
||||
|
@ -123,33 +78,15 @@ export const AccountGallery: React.FC<{
|
|||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const blockedBy = useAppSelector(
|
||||
(state) =>
|
||||
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
|
||||
);
|
||||
const suspended = useAppSelector(
|
||||
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
|
||||
);
|
||||
const isAccount = !!account;
|
||||
const remote = account?.acct !== account?.username;
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? getAccountHidden(state, accountId) : false,
|
||||
);
|
||||
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
|
||||
const maxId = attachments.last()?.getIn(['status', 'id']) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
dispatch(lookupAccount(acct));
|
||||
}
|
||||
}, [dispatch, accountId, acct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !isAccount) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
|
||||
if (accountId && isAccount) {
|
||||
void dispatch(expandAccountMediaTimeline(accountId));
|
||||
}
|
||||
|
@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
|
|||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (remote && attachments.isEmpty()) {
|
||||
} else if (attachments.isEmpty()) {
|
||||
emptyMessage = <RemoteHint accountId={accountId} />;
|
||||
} else {
|
||||
emptyMessage = (
|
||||
|
@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
|
|||
)
|
||||
}
|
||||
alwaysPrepend
|
||||
append={remote && accountId && <RemoteHint accountId={accountId} />}
|
||||
append={accountId && <RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_gallery'
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
unmuteAccount,
|
||||
pinAccount,
|
||||
unpinAccount,
|
||||
removeAccountFromFollowers,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { initBlockModal } from 'mastodon/actions/blocks';
|
||||
import { mentionCompose, directCompose } from 'mastodon/actions/compose';
|
||||
|
@ -37,12 +38,12 @@ import {
|
|||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
|
||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||
|
@ -50,8 +51,7 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
|||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { DropdownMenu } from 'mastodon/models/dropdown_menu';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
|
@ -63,18 +63,6 @@ import { MemorialNote } from './memorial_note';
|
|||
import { MovedNote } from './moved_note';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||
cancel_follow_request: {
|
||||
id: 'account.cancel_follow_request',
|
||||
defaultMessage: 'Withdraw follow request',
|
||||
},
|
||||
requested: {
|
||||
id: 'account.requested',
|
||||
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
||||
},
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: {
|
||||
|
@ -165,6 +153,23 @@ const messages = defineMessages({
|
|||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
|
@ -179,20 +184,6 @@ const titleFromAccount = (account: Account) => {
|
|||
return `${prefix} (@${acct})`;
|
||||
};
|
||||
|
||||
const messageForFollowButton = (relationship?: Relationship) => {
|
||||
if (!relationship) return messages.follow;
|
||||
|
||||
if (relationship.get('following') && relationship.get('followed_by')) {
|
||||
return messages.mutual;
|
||||
} else if (relationship.get('following') || relationship.get('requested')) {
|
||||
return messages.unfollow;
|
||||
} else if (relationship.get('followed_by')) {
|
||||
return messages.followBack;
|
||||
} else {
|
||||
return messages.follow;
|
||||
}
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
@ -215,20 +206,6 @@ export const AccountHeader: React.FC<{
|
|||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const handleLinkClick = useLinks();
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.following || relationship?.requested) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
|
@ -365,23 +342,6 @@ export const AccountHeader: React.FC<{
|
|||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleInteractionModal = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: account.id,
|
||||
url: account.uri,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
|
@ -417,10 +377,6 @@ export const AccountHeader: React.FC<{
|
|||
});
|
||||
}, [account]);
|
||||
|
||||
const handleEditProfile = useCallback(() => {
|
||||
window.open('/settings/profile', '_blank');
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
|
@ -456,7 +412,7 @@ export const AccountHeader: React.FC<{
|
|||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: DropdownMenu = [];
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
|
@ -545,9 +501,7 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
account.getIn(['relationship', 'endorsed'])
|
||||
? messages.unendorse
|
||||
: messages.endorse,
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
|
@ -558,6 +512,39 @@ export const AccountHeader: React.FC<{
|
|||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(removeAccountFromFollowers({ accountId }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
|
@ -656,6 +643,8 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
accountId,
|
||||
account,
|
||||
relationship,
|
||||
permissions,
|
||||
|
@ -680,33 +669,72 @@ export const AccountHeader: React.FC<{
|
|||
return null;
|
||||
}
|
||||
|
||||
let actionBtn, bellBtn, lockedIcon, shareBtn;
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info = [];
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship?.blocking) {
|
||||
info.push(
|
||||
<span key='blocked' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocked' defaultMessage='Blocked' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (me !== account.id && relationship) {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
info.push(
|
||||
<span key='mutual' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
info.push(
|
||||
<span key='followed_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
info.push(
|
||||
<span key='requested_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (me !== account.id && relationship?.muting) {
|
||||
info.push(
|
||||
<span key='muted' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muted' defaultMessage='Muted' />
|
||||
</span>,
|
||||
);
|
||||
} else if (me !== account.id && relationship?.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocked' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocked'
|
||||
defaultMessage='Domain blocked'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
if (relationship.blocking) {
|
||||
info.push(
|
||||
<span key='blocking' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.muting) {
|
||||
info.push(
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.requested || relationship?.following) {
|
||||
|
@ -750,43 +778,17 @@ export const AccountHeader: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
if (me !== account.id) {
|
||||
if (signedIn && !relationship) {
|
||||
// Wait until the relationship is loaded
|
||||
actionBtn = (
|
||||
<Button disabled>
|
||||
<LoadingIndicator />
|
||||
</Button>
|
||||
);
|
||||
} else if (!relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
disabled={relationship?.blocked_by}
|
||||
className={classNames({
|
||||
'button--destructive':
|
||||
relationship?.following || relationship?.requested,
|
||||
})}
|
||||
text={intl.formatMessage(messageForFollowButton(relationship))}
|
||||
onClick={signedIn ? handleFollow : handleInteractionModal}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (relationship?.blocking) {
|
||||
actionBtn = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.edit_profile)}
|
||||
onClick={handleEditProfile}
|
||||
text={intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionBtn = <FollowButton accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (account.moved && !relationship?.following) {
|
||||
|
@ -819,7 +821,7 @@ export const AccountHeader: React.FC<{
|
|||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.get('roles', []).forEach((role) => {
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
|
@ -879,13 +881,11 @@ export const AccountHeader: React.FC<{
|
|||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
{!hidden && actionBtn}
|
||||
</div>
|
||||
|
@ -1031,6 +1031,9 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
|
|
|
@ -7,12 +7,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||
|
@ -21,6 +19,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
|
|||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import StatusList from '../../components/status_list';
|
||||
import Column from '../ui/components/column';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
|
||||
import { AccountHeader } from './components/account_header';
|
||||
import { LimitedAccountHint } from './components/limited_account_hint';
|
||||
|
@ -47,11 +46,8 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
|
|||
|
||||
return {
|
||||
accountId,
|
||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
||||
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
|
||||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
|
||||
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||
|
@ -60,24 +56,6 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
|
|||
};
|
||||
};
|
||||
|
||||
const RemoteHint = ({ accountId, url }) => {
|
||||
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
|
||||
const domain = acct ? acct.split('@')[1] : undefined;
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={url}
|
||||
message={<FormattedMessage id='hints.profiles.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
|
||||
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
RemoteHint.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -89,7 +67,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
accountId: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
featuredStatusIds: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
withReplies: PropTypes.bool,
|
||||
|
@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
isAccount: PropTypes.bool,
|
||||
suspended: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
remote: PropTypes.bool,
|
||||
remoteUrl: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
|
@ -191,8 +166,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
|
||||
}
|
||||
|
||||
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
<StatusList
|
||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={forceEmptyState ? emptyList : statusIds}
|
||||
featuredStatusIds={featuredStatusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
@ -13,6 +12,7 @@ import classNames from 'classnames';
|
|||
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { useSpring, animated } from '@react-spring/web';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import { length } from 'stringz';
|
||||
// eslint-disable-next-line import/extensions
|
||||
|
@ -31,7 +31,7 @@ import Audio from 'mastodon/features/audio';
|
|||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import { Video, getPointerPosition } from 'mastodon/features/video';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { me, reduceMotion } from 'mastodon/initial_state';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
@ -105,6 +105,17 @@ const Preview: React.FC<{
|
|||
position: FocalPoint;
|
||||
onPositionChange: (arg0: FocalPoint) => void;
|
||||
}> = ({ mediaId, position, onPositionChange }) => {
|
||||
const draggingRef = useRef<boolean>(false);
|
||||
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
|
||||
|
||||
const [x, y] = position;
|
||||
const style = useSpring({
|
||||
to: {
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
},
|
||||
immediate: reduceMotion || draggingRef.current,
|
||||
});
|
||||
const media = useAppSelector((state) =>
|
||||
(
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
|
@ -117,9 +128,6 @@ const Preview: React.FC<{
|
|||
);
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [x, y] = position;
|
||||
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
|
||||
const draggingRef = useRef<boolean>(false);
|
||||
|
||||
const setRef = useCallback(
|
||||
(e: HTMLImageElement | HTMLVideoElement | null) => {
|
||||
|
@ -134,36 +142,30 @@ const Preview: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves
|
||||
onPositionChange([x, y]);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
draggingRef.current = false;
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
|
||||
|
||||
setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
},
|
||||
[setDragging, onPositionChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (draggingRef.current) {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
onPositionChange([x, y]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
}, [setDragging, onPositionChange]);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
@ -179,10 +181,7 @@ const Preview: React.FC<{
|
|||
role='presentation'
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
<animated.div className='focal-point__reticle' style={style} />
|
||||
</div>
|
||||
);
|
||||
} else if (media.get('type') === 'gifv') {
|
||||
|
@ -194,10 +193,7 @@ const Preview: React.FC<{
|
|||
alt=''
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
<animated.div className='focal-point__reticle' style={style} />
|
||||
</div>
|
||||
);
|
||||
} else if (media.get('type') === 'video') {
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'bookmarks'),
|
||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||
});
|
||||
|
||||
class Bookmarks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Bookmarks));
|
116
app/javascript/mastodon/features/bookmarked_statuses/index.tsx
Normal file
116
app/javascript/mastodon/features/bookmarked_statuses/index.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import {
|
||||
fetchBookmarkedStatuses,
|
||||
expandBookmarkedStatuses,
|
||||
} from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const Bookmarks: React.FC<{
|
||||
columnId: string;
|
||||
multiColumn: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'bookmarks'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.bookmarked_statuses'
|
||||
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Bookmarks;
|
|
@ -2,64 +2,82 @@ import { useMemo } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
export const ActionBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
export const ActionBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
return ([
|
||||
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
|
||||
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
|
||||
return [
|
||||
{
|
||||
text: intl.formatMessage(messages.edit_profile),
|
||||
href: '/settings/profile',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
|
||||
{
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
to: '/follow_requests',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
|
||||
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
|
||||
{ text: intl.formatMessage(messages.lists), to: '/lists' },
|
||||
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
|
||||
{
|
||||
text: intl.formatMessage(messages.followed_tags),
|
||||
to: '/followed_tags',
|
||||
},
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domain_blocks),
|
||||
to: '/domain_blocks',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
||||
]);
|
||||
];
|
||||
}, [intl, dispatch]);
|
||||
|
||||
return (
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='bars'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
);
|
||||
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||
};
|
|
@ -24,7 +24,7 @@ import AvatarComposite from 'mastodon/components/avatar_composite';
|
|||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
|
||||
|
@ -205,7 +205,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={lastStatus}
|
||||
items={menu}
|
||||
|
|
|
@ -25,7 +25,7 @@ export const Card = ({ id, source }) => {
|
|||
const dispatch = useDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissSuggestion(id));
|
||||
dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
let label;
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: getStatusList(state, 'favourites'),
|
||||
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
||||
});
|
||||
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchFavouritedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFavouritedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Favourites));
|
116
app/javascript/mastodon/features/favourited_statuses/index.tsx
Normal file
116
app/javascript/mastodon/features/favourited_statuses/index.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import {
|
||||
fetchFavouritedStatuses,
|
||||
expandFavouritedStatuses,
|
||||
} from 'mastodon/actions/favourites';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({
|
||||
columnId,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'favourites'),
|
||||
);
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['favourites', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('FAVOURITES', {}));
|
||||
}
|
||||
}, [dispatch, columnId]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dir: number) => {
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
},
|
||||
[dispatch, columnId],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandFavouritedStatuses());
|
||||
}, [dispatch]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.favourited_statuses'
|
||||
defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`favourited_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Favourites;
|
|
@ -1,95 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
class FollowedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hashtags: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{hashtags.map((hashtag) => (
|
||||
<Hashtag
|
||||
key={hashtag.get('name')}
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(FollowedTags));
|
161
app/javascript/mastodon/features/followed_tags/index.tsx
Normal file
161
app/javascript/mastodon/features/followed_tags/index.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
|
||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const FollowedTag: React.FC<{
|
||||
tag: ApiHashtagJSON;
|
||||
onUnfollow: (arg0: string) => void;
|
||||
}> = ({ tag, onUnfollow }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const tagId = tag.name;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
void dispatch(unfollowHashtag({ tagId })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
onUnfollow(tagId);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [dispatch, onUnfollow, tagId]);
|
||||
|
||||
const people =
|
||||
parseInt(tag.history[0].accounts) +
|
||||
parseInt(tag.history[1]?.accounts ?? '');
|
||||
|
||||
return (
|
||||
<Hashtag
|
||||
name={tag.name}
|
||||
to={`/tags/${tag.name}`}
|
||||
withGraph={false}
|
||||
people={people}
|
||||
>
|
||||
<Button onClick={handleClick}>
|
||||
<FormattedMessage id='account.unfollow' defaultMessage='Unfollow' />
|
||||
</Button>
|
||||
</Hashtag>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [next, setNext] = useState<string | undefined>();
|
||||
const hasMore = !!next;
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags()
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(next)
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setLoading(false);
|
||||
setTags((previousTags) => [...previousTags, ...tags]);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext, next]);
|
||||
|
||||
const handleUnfollow = useCallback(
|
||||
(tagId: string) => {
|
||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||
},
|
||||
[setTags],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const emptyMessage = (
|
||||
<FormattedMessage
|
||||
id='empty_column.followed_tags'
|
||||
defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
iconComponent={TagIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={loading}
|
||||
showLoading={loading && tags.length === 0}
|
||||
onLoadMore={handleLoadMore}
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<FollowedTag key={tag.name} tag={tag} onUnfollow={handleUnfollow} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FollowedTags;
|
|
@ -12,8 +12,8 @@ import {
|
|||
} from 'mastodon/actions/tags_typed';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import DropdownMenu from 'mastodon/containers/dropdown_menu_container';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
@ -153,13 +153,11 @@ export const HashtagHeader: React.FC<{
|
|||
|
||||
<div className='hashtag-header__header__buttons'>
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
size={24}
|
||||
direction='right'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -13,9 +13,9 @@ import { fetchLists } from 'mastodon/actions/lists';
|
|||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -60,12 +60,11 @@ const ListItem: React.FC<{
|
|||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
scrollKey='lists'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,7 @@ 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 { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { toCappedNumber } from 'mastodon/utils/numbers';
|
||||
|
||||
|
@ -105,11 +105,10 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -23,7 +23,7 @@ import Column from 'mastodon/components/column';
|
|||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
|
||||
import { NotificationRequest } from './components/notification_request';
|
||||
import { PolicyControls } from './components/policy_controls';
|
||||
|
@ -126,7 +126,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
|||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
|
||||
</div>
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
|
@ -139,7 +139,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
|||
</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
</DropdownMenuContainer>
|
||||
</Dropdown>
|
||||
<div className='column-header__select-row__mode-button'>
|
||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||
{selectionMode ? (
|
||||
|
|
|
@ -24,7 +24,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
|||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -317,7 +317,7 @@ class ActionBar extends PureComponent {
|
|||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
<Dropdown icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
|
|||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { DropdownMenu } from 'mastodon/components/dropdown_menu';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
browseHashtag: {
|
||||
id: 'hashtag.browse',
|
||||
defaultMessage: 'Browse posts in #{hashtag}',
|
||||
},
|
||||
browseHashtagFromAccount: {
|
||||
id: 'hashtag.browse_from_account',
|
||||
defaultMessage: 'Browse posts from @{name} in #{hashtag}',
|
||||
},
|
||||
muteHashtag: { id: 'hashtag.mute', defaultMessage: 'Mute #{hashtag}' },
|
||||
});
|
||||
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
const isHashtagLink = (
|
||||
element: HTMLAnchorElement | null,
|
||||
): element is HTMLAnchorElement => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return element.matches('[data-menu-hashtag]');
|
||||
};
|
||||
|
||||
interface TargetParams {
|
||||
hashtag?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export const HashtagMenuController: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({});
|
||||
const targetRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const location = useLocation();
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
targetRef.current = null;
|
||||
}, [setOpen, location]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('a');
|
||||
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isHashtagLink(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashtag = target.text.replace(/^#/, '');
|
||||
const accountId = target.getAttribute('data-menu-hashtag');
|
||||
|
||||
if (!hashtag || !accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
targetRef.current = target;
|
||||
setOpen(true);
|
||||
setTargetParams({ hashtag, accountId });
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick, { capture: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [setTargetParams, setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
targetRef.current = null;
|
||||
}, [setOpen]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: intl.formatMessage(messages.browseHashtag, {
|
||||
hashtag,
|
||||
}),
|
||||
to: `/tags/${hashtag}`,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.browseHashtagFromAccount, {
|
||||
hashtag,
|
||||
name: account?.username,
|
||||
}),
|
||||
to: `/@${account?.acct}/tagged/${hashtag}`,
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(messages.muteHashtag, {
|
||||
hashtag,
|
||||
}),
|
||||
href: '/filters',
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
[intl, hashtag, account],
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
flip
|
||||
target={targetRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
{...arrowProps}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
onClose={handleClose}
|
||||
openedViaKeyboard={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
|
@ -31,6 +31,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
|
|||
import BundleColumnError from './components/bundle_column_error';
|
||||
import Header from './components/header';
|
||||
import { UploadArea } from './components/upload_area';
|
||||
import { HashtagMenuController } from './components/hashtag_menu_controller';
|
||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||
import LoadingBarContainer from './containers/loading_bar_container';
|
||||
import ModalContainer from './containers/modal_container';
|
||||
|
@ -73,6 +74,7 @@ import {
|
|||
About,
|
||||
PrivacyPolicy,
|
||||
TermsOfService,
|
||||
AccountFeatured,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
|
@ -236,6 +238,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
||||
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
|
||||
|
@ -609,6 +612,7 @@ class UI extends PureComponent {
|
|||
{layout !== 'mobile' && <PictureInPicture />}
|
||||
<AlertsController />
|
||||
{!disableHoverCards && <HoverCardController />}
|
||||
<HashtagMenuController />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
|
|
|
@ -66,6 +66,10 @@ export function AccountGallery () {
|
|||
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
|
||||
}
|
||||
|
||||
export function AccountFeatured() {
|
||||
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
|
||||
}
|
||||
|
||||
export function Followers () {
|
||||
return import(/* webpackChunkName: "features/followers" */'../../followers');
|
||||
}
|
||||
|
|
37
app/javascript/mastodon/hooks/useAccountId.ts
Normal file
37
app/javascript/mastodon/hooks/useAccountId.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function useAccountId() {
|
||||
const { acct, id } = useParams<Params>();
|
||||
const accountId = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
);
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const isAccount = !!account;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
dispatch(lookupAccount(acct));
|
||||
} else if (!isAccount) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, acct, isAccount]);
|
||||
|
||||
return accountId;
|
||||
}
|
20
app/javascript/mastodon/hooks/useAccountVisibility.ts
Normal file
20
app/javascript/mastodon/hooks/useAccountVisibility.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export function useAccountVisibility(accountId?: string) {
|
||||
const blockedBy = useAppSelector(
|
||||
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
|
||||
);
|
||||
const suspended = useAppSelector(
|
||||
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
|
||||
);
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? Boolean(getAccountHidden(state, accountId)) : false,
|
||||
);
|
||||
|
||||
return {
|
||||
blockedBy,
|
||||
suspended,
|
||||
hidden,
|
||||
};
|
||||
}
|
|
@ -18,7 +18,6 @@
|
|||
"account.blocked": "Geblokkeer",
|
||||
"account.cancel_follow_request": "Herroep volgversoek",
|
||||
"account.disable_notifications": "Hou op om my van @{name} se plasings te laat weet",
|
||||
"account.domain_blocked": "Domein geblokkeer",
|
||||
"account.edit_profile": "Redigeer profiel",
|
||||
"account.enable_notifications": "Laat weet my wanneer @{name} iets plaas",
|
||||
"account.endorse": "Toon op profiel",
|
||||
|
|
|
@ -19,13 +19,11 @@
|
|||
"account.blocked": "Blocau",
|
||||
"account.cancel_follow_request": "Retirar solicitut de seguimiento",
|
||||
"account.disable_notifications": "Deixar de notificar-me quan @{name} publique bella cosa",
|
||||
"account.domain_blocked": "Dominio blocau",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Notificar-me quan @{name} publique bella cosa",
|
||||
"account.endorse": "Amostrar en perfil",
|
||||
"account.featured_tags.last_status_at": "Zaguera publicación lo {date}",
|
||||
"account.featured_tags.last_status_never": "Sin publicacions",
|
||||
"account.featured_tags.title": "Etiquetas destacadas de {name}",
|
||||
"account.follow": "Seguir",
|
||||
"account.followers": "Seguidores",
|
||||
"account.followers.empty": "Encara no sigue dengún a este usuario.",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "نسخ الرابط إلى الملف الشخصي",
|
||||
"account.direct": "إشارة خاصة لـ @{name}",
|
||||
"account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}",
|
||||
"account.domain_blocked": "اسم النِّطاق محظور",
|
||||
"account.edit_profile": "تعديل الملف الشخصي",
|
||||
"account.enable_notifications": "أشعرني عندما ينشر @{name}",
|
||||
"account.endorse": "أوصِ به على صفحتك الشخصية",
|
||||
"account.featured_tags.last_status_at": "آخر منشور في {date}",
|
||||
"account.featured_tags.last_status_never": "لا توجد رسائل",
|
||||
"account.featured_tags.title": "وسوم {name} المميَّزة",
|
||||
"account.follow": "متابعة",
|
||||
"account.follow_back": "تابعه بالمثل",
|
||||
"account.followers": "مُتابِعون",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "كتم الإشعارات",
|
||||
"account.mute_short": "اكتم",
|
||||
"account.muted": "مَكتوم",
|
||||
"account.mutual": "متبادلة",
|
||||
"account.no_bio": "لم يتم تقديم وصف.",
|
||||
"account.open_original_page": "افتح الصفحة الأصلية",
|
||||
"account.posts": "منشورات",
|
||||
|
|
|
@ -22,12 +22,10 @@
|
|||
"account.copy": "Copiar l'enlllaz al perfil",
|
||||
"account.direct": "Mentar a @{name} per privao",
|
||||
"account.disable_notifications": "Dexar d'avisame cuando @{name} espublice artículos",
|
||||
"account.domain_blocked": "Dominiu bloquiáu",
|
||||
"account.edit_profile": "Editar el perfil",
|
||||
"account.enable_notifications": "Avisame cuando @{name} espublice artículos",
|
||||
"account.endorse": "Destacar nel perfil",
|
||||
"account.featured_tags.last_status_never": "Nun hai nenguna publicación",
|
||||
"account.featured_tags.title": "Etiquetes destacaes de: {name}",
|
||||
"account.follow": "Siguir",
|
||||
"account.follow_back": "Siguir tamién",
|
||||
"account.followers": "Siguidores",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Profil linkini kopyala",
|
||||
"account.direct": "@{name} istifadəçisini fərdi olaraq etiketlə",
|
||||
"account.disable_notifications": "@{name} paylaşım edəndə mənə bildiriş göndərməyi dayandır",
|
||||
"account.domain_blocked": "Domen bloklanıb",
|
||||
"account.edit_profile": "Profili redaktə et",
|
||||
"account.enable_notifications": "@{name} paylaşım edəndə mənə bildiriş göndər",
|
||||
"account.endorse": "Profildə seçilmişlərə əlavə et",
|
||||
"account.featured_tags.last_status_at": "Son paylaşım {date} tarixində olub",
|
||||
"account.featured_tags.last_status_never": "Paylaşım yoxdur",
|
||||
"account.featured_tags.title": "{name} istifadəçisinin seçilmiş heşteqləri",
|
||||
"account.follow": "İzlə",
|
||||
"account.follow_back": "Sən də izlə",
|
||||
"account.followers": "İzləyicilər",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Bildirişləri səssizləşdir",
|
||||
"account.mute_short": "Səssizləşdir",
|
||||
"account.muted": "Səssizləşdirilib",
|
||||
"account.mutual": "Ortaq",
|
||||
"account.no_bio": "Təsvir göstərilməyib.",
|
||||
"account.open_original_page": "Orijinal səhifəni aç",
|
||||
"account.posts": "Paylaşım",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Скапіраваць спасылку на профіль",
|
||||
"account.direct": "Згадаць асабіста @{name}",
|
||||
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
||||
"account.domain_blocked": "Дамен заблакаваны",
|
||||
"account.edit_profile": "Рэдагаваць профіль",
|
||||
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
||||
"account.endorse": "Паказваць у профілі",
|
||||
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
|
||||
"account.featured_tags.last_status_never": "Няма допісаў",
|
||||
"account.featured_tags.title": "Тэгі, выбраныя {name}",
|
||||
"account.follow": "Падпісацца",
|
||||
"account.follow_back": "Падпісацца ў адказ",
|
||||
"account.followers": "Падпісчыкі",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Не апавяшчаць",
|
||||
"account.mute_short": "Ігнараваць",
|
||||
"account.muted": "Ігнаруецца",
|
||||
"account.mutual": "Узаемныя",
|
||||
"account.no_bio": "Апісанне адсутнічае.",
|
||||
"account.open_original_page": "Адкрыць арыгінальную старонку",
|
||||
"account.posts": "Допісы",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Копиране на връзка към профила",
|
||||
"account.direct": "Частно споменаване на @{name}",
|
||||
"account.disable_notifications": "Спиране на известяване при публикуване от @{name}",
|
||||
"account.domain_blocked": "Блокиран домейн",
|
||||
"account.edit_profile": "Редактиране на профила",
|
||||
"account.enable_notifications": "Известяване при публикуване от @{name}",
|
||||
"account.endorse": "Представи в профила",
|
||||
"account.featured": "Препоръчано",
|
||||
"account.featured.hashtags": "Хаштагове",
|
||||
"account.featured.posts": "Публикации",
|
||||
"account.featured_tags.last_status_at": "Последна публикация на {date}",
|
||||
"account.featured_tags.last_status_never": "Няма публикации",
|
||||
"account.featured_tags.title": "Главни хаштагове на {name}",
|
||||
"account.follow": "Последване",
|
||||
"account.follow_back": "Последване взаимно",
|
||||
"account.followers": "Последователи",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Заглушаване на известията",
|
||||
"account.mute_short": "Заглушаване",
|
||||
"account.muted": "Заглушено",
|
||||
"account.mutual": "Взаимни",
|
||||
"account.no_bio": "Няма представен опис.",
|
||||
"account.open_original_page": "Отваряне на първообразната страница",
|
||||
"account.posts": "Публикации",
|
||||
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
|
||||
"account.unblock": "Отблокиране на @{name}",
|
||||
"account.unblock_domain": "Отблокиране на домейн {domain}",
|
||||
"account.unblock_domain_short": "Отблокиране",
|
||||
"account.unblock_short": "Отблокиране",
|
||||
"account.unendorse": "Не включвайте в профила",
|
||||
"account.unfollow": "Стоп на следването",
|
||||
|
@ -377,6 +378,8 @@
|
|||
"generic.saved": "Запазено",
|
||||
"getting_started.heading": "Първи стъпки",
|
||||
"hashtag.admin_moderation": "Отваряне на модериращия интерфейс за #{name}",
|
||||
"hashtag.browse": "Разглеждане на публикации в #{hashtag}",
|
||||
"hashtag.browse_from_account": "Разглеждане на публикации от @{name} из #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "и {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "или {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "без {additional}",
|
||||
|
@ -390,6 +393,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} публикация} other {{counter} публикации}} днес",
|
||||
"hashtag.follow": "Следване на хаштаг",
|
||||
"hashtag.mute": "Заглушаване на #{hashtag}",
|
||||
"hashtag.unfollow": "Спиране на следване на хаштаг",
|
||||
"hashtags.and_other": "…и {count, plural, other {# още}}",
|
||||
"hints.profiles.followers_may_be_missing": "Последователи за този профил може да липсват.",
|
||||
|
@ -674,7 +678,7 @@
|
|||
"onboarding.follows.title": "Последвайте хора, за да започнете",
|
||||
"onboarding.profile.discoverable": "Правене на моя профил откриваем",
|
||||
"onboarding.profile.discoverable_hint": "Включвайки откриваемостта в Mastodon, вашите публикации може да се появят при резултатите от търсене и изгряващи неща, и вашия профил може да бъде предложен на хора с подобни интереси като вашите.",
|
||||
"onboarding.profile.display_name": "Името на показ",
|
||||
"onboarding.profile.display_name": "Показвано име",
|
||||
"onboarding.profile.display_name_hint": "Вашето пълно име или псевдоним…",
|
||||
"onboarding.profile.note": "Биография",
|
||||
"onboarding.profile.note_hint": "Може да @споменавате други хора или #хаштагове…",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "অবতারের সংযোগ অনুলিপি করো",
|
||||
"account.direct": "গোপনে মেনশন করুন @{name}",
|
||||
"account.disable_notifications": "আমাকে জানানো বন্ধ করো যখন @{name} পোস্ট করবে",
|
||||
"account.domain_blocked": "ডোমেইন ব্লক করা",
|
||||
"account.edit_profile": "প্রোফাইল সম্পাদনা করুন",
|
||||
"account.enable_notifications": "আমাকে জানাবে যখন @{name} পোস্ট করবে",
|
||||
"account.endorse": "প্রোফাইলে ফিচার করুন",
|
||||
"account.featured_tags.last_status_at": "{date} এ সর্বশেষ পোস্ট",
|
||||
"account.featured_tags.last_status_never": "কোনো পোস্ট নেই",
|
||||
"account.featured_tags.title": "{name} এর ফিচার করা Hashtag সমূহ",
|
||||
"account.follow": "অনুসরণ",
|
||||
"account.follow_back": "তাকে অনুসরণ করো",
|
||||
"account.followers": "অনুসরণকারী",
|
||||
|
|
|
@ -22,13 +22,11 @@
|
|||
"account.copy": "Eilañ al liamm war-zu ho profil",
|
||||
"account.direct": "Menegiñ @{name} ent-prevez",
|
||||
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
||||
"account.domain_blocked": "Domani stanket",
|
||||
"account.edit_profile": "Kemmañ ar profil",
|
||||
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
||||
"account.endorse": "Lakaat war-wel war ar profil",
|
||||
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
|
||||
"account.featured_tags.last_status_never": "Embannadur ebet",
|
||||
"account.featured_tags.title": "Hashtagoù pennañ {name}",
|
||||
"account.follow": "Heuliañ",
|
||||
"account.follow_back": "Heuliañ d'ho tro",
|
||||
"account.followers": "Tud koumanantet",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Copia l'enllaç al perfil",
|
||||
"account.direct": "Menciona privadament @{name}",
|
||||
"account.disable_notifications": "Deixa de notificar-me els tuts de @{name}",
|
||||
"account.domain_blocked": "Domini blocat",
|
||||
"account.edit_profile": "Edita el perfil",
|
||||
"account.enable_notifications": "Notifica'm els tuts de @{name}",
|
||||
"account.endorse": "Recomana en el perfil",
|
||||
"account.featured": "Destacat",
|
||||
"account.featured.hashtags": "Etiquetes",
|
||||
"account.featured.posts": "Publicacions",
|
||||
"account.featured_tags.last_status_at": "Darrer tut el {date}",
|
||||
"account.featured_tags.last_status_never": "No hi ha tuts",
|
||||
"account.featured_tags.title": "etiquetes destacades de {name}",
|
||||
"account.follow": "Segueix",
|
||||
"account.follow_back": "Segueix tu també",
|
||||
"account.followers": "Seguidors",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Silencia les notificacions",
|
||||
"account.mute_short": "Silencia",
|
||||
"account.muted": "Silenciat",
|
||||
"account.mutual": "Mutu",
|
||||
"account.no_bio": "No s'ha proporcionat cap descripció.",
|
||||
"account.open_original_page": "Obre la pàgina original",
|
||||
"account.posts": "Tuts",
|
||||
|
@ -391,6 +391,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} avui",
|
||||
"hashtag.follow": "Segueix l'etiqueta",
|
||||
"hashtag.mute": "Silencia #{hashtag}",
|
||||
"hashtag.unfollow": "Deixa de seguir l'etiqueta",
|
||||
"hashtags.and_other": "…i {count, plural, other {# més}}",
|
||||
"hints.profiles.followers_may_be_missing": "Es poden haver perdut seguidors d'aquest perfil.",
|
||||
|
|
|
@ -22,13 +22,11 @@
|
|||
"account.copy": "ڕوونووسی بەستەر بۆ توت",
|
||||
"account.direct": "بە شێوەیەکی تایبەت باسی @{name} بکە",
|
||||
"account.disable_notifications": "ئاگانامە مەنێرە بۆم کاتێک @{name} پۆست دەکرێت",
|
||||
"account.domain_blocked": "دۆمەین قەپاتکرا",
|
||||
"account.edit_profile": "دەستکاری پرۆفایل",
|
||||
"account.enable_notifications": "ئاگادارم بکەوە کاتێک @{name} بابەتەکان",
|
||||
"account.endorse": "ناساندن لە پرۆفایل",
|
||||
"account.featured_tags.last_status_at": "دوایین پۆست لە {date}",
|
||||
"account.featured_tags.last_status_never": "هیچ پۆستێک نییە",
|
||||
"account.featured_tags.title": "هاشتاگە تایبەتەکانی {name}",
|
||||
"account.follow": "بەدواداچوون",
|
||||
"account.follow_back": "فۆڵۆو بکەنەوە",
|
||||
"account.followers": "شوێنکەوتووان",
|
||||
|
@ -49,7 +47,6 @@
|
|||
"account.mute_notifications_short": "پاڵ بە ئاگادارکردنەوەکانەوە بنێ",
|
||||
"account.mute_short": "بێدەنگ",
|
||||
"account.muted": "بێ دەنگ",
|
||||
"account.mutual": "دوولایەنە",
|
||||
"account.no_bio": "هیچ وەسفێک نەخراوەتەڕوو.",
|
||||
"account.open_original_page": "لاپەڕەی ئەسڵی بکەرەوە",
|
||||
"account.posts": "نووسراوەکان",
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
"account.blocked": "Bluccatu",
|
||||
"account.cancel_follow_request": "Withdraw follow request",
|
||||
"account.disable_notifications": "Ùn mi nutificate più quandu @{name} pubblica qualcosa",
|
||||
"account.domain_blocked": "Duminiu piattatu",
|
||||
"account.edit_profile": "Mudificà u prufile",
|
||||
"account.enable_notifications": "Nutificate mi quandu @{name} pubblica qualcosa",
|
||||
"account.endorse": "Fà figurà nant'à u prufilu",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Kopírovat odkaz na profil",
|
||||
"account.direct": "Soukromě zmínit @{name}",
|
||||
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
|
||||
"account.domain_blocked": "Doména blokována",
|
||||
"account.edit_profile": "Upravit profil",
|
||||
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
|
||||
"account.endorse": "Zvýraznit na profilu",
|
||||
"account.featured": "Doporučené",
|
||||
"account.featured.hashtags": "Hashtagy",
|
||||
"account.featured.posts": "Příspěvky",
|
||||
"account.featured_tags.last_status_at": "Poslední příspěvek {date}",
|
||||
"account.featured_tags.last_status_never": "Žádné příspěvky",
|
||||
"account.featured_tags.title": "Hlavní hashtagy uživatele {name}",
|
||||
"account.follow": "Sledovat",
|
||||
"account.follow_back": "Také sledovat",
|
||||
"account.followers": "Sledující",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Ztlumit upozornění",
|
||||
"account.mute_short": "Ztlumit",
|
||||
"account.muted": "Skrytý",
|
||||
"account.mutual": "Vzájemné",
|
||||
"account.no_bio": "Nebyl poskytnut žádný popis.",
|
||||
"account.open_original_page": "Otevřít původní stránku",
|
||||
"account.posts": "Příspěvky",
|
||||
|
@ -378,6 +378,8 @@
|
|||
"generic.saved": "Uloženo",
|
||||
"getting_started.heading": "Začínáme",
|
||||
"hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}",
|
||||
"hashtag.browse": "Procházet příspěvky na #{hashtag}",
|
||||
"hashtag.browse_from_account": "Procházet příspěvky od @{name} v #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "a {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "nebo {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "bez {additional}",
|
||||
|
@ -391,6 +393,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
|
||||
"hashtag.counter_by_uses_today": "Dnes {count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
|
||||
"hashtag.follow": "Sledovat hashtag",
|
||||
"hashtag.mute": "Skrýt #{hashtag}",
|
||||
"hashtag.unfollow": "Přestat sledovat hashtag",
|
||||
"hashtags.and_other": "…a {count, plural, one {# další} few {# další} other {# dalších}}",
|
||||
"hints.profiles.followers_may_be_missing": "Sledující mohou pro tento profil chybět.",
|
||||
|
@ -560,7 +563,7 @@
|
|||
"notification.admin.sign_up.name_and_others": "{name} a {count, plural, one {# další} few {# další} many {# dalších} other {# dalších}} se zaregistrovali",
|
||||
"notification.annual_report.message": "Váš #Wrapstodon {year} na Vás čeká! Podívejte se, jak vypadal tento Váš rok na Mastodonu!",
|
||||
"notification.annual_report.view": "Zobrazit #Wrapstodon",
|
||||
"notification.favourite": "{name} si oblíbil*a váš příspěvek",
|
||||
"notification.favourite": "{name} si oblíbil váš příspěvek",
|
||||
"notification.favourite.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Váš příspěvek",
|
||||
"notification.favourite_pm": "{name} si oblíbil vaši soukromou zmínku",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Vaši soukromou zmínku",
|
||||
|
@ -573,7 +576,7 @@
|
|||
"notification.label.private_reply": "Privátní odpověď",
|
||||
"notification.label.reply": "Odpověď",
|
||||
"notification.mention": "Zmínka",
|
||||
"notification.mentioned_you": "{name} vás zmínil*a",
|
||||
"notification.mentioned_you": "{name} vás zmínil",
|
||||
"notification.moderation-warning.learn_more": "Zjistit více",
|
||||
"notification.moderation_warning": "Obdrželi jste varování od moderátorů",
|
||||
"notification.moderation_warning.action_delete_statuses": "Některé z vašich příspěvků byly odstraněny.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"about.blocks": "Gweinyddion wedi'u cymedroli",
|
||||
"about.blocks": "Gweinyddion a gyfyngir",
|
||||
"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",
|
||||
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Copïo dolen i'r proffil",
|
||||
"account.direct": "Crybwyll yn breifat @{name}",
|
||||
"account.disable_notifications": "Stopiwch fy hysbysu pan fydd @{name} yn postio",
|
||||
"account.domain_blocked": "Parth wedi'i rwystro",
|
||||
"account.edit_profile": "Golygu proffil",
|
||||
"account.edit_profile": "Golygu'r proffil",
|
||||
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
|
||||
"account.endorse": "Dangos ar fy mhroffil",
|
||||
"account.featured": "Dethol",
|
||||
"account.featured.hashtags": "Hashnodau",
|
||||
"account.featured.posts": "Postiadau",
|
||||
"account.featured_tags.last_status_at": "Y postiad olaf ar {date}",
|
||||
"account.featured_tags.last_status_never": "Dim postiadau",
|
||||
"account.featured_tags.title": "Prif hashnodau {name}",
|
||||
"account.follow": "Dilyn",
|
||||
"account.follow_back": "Dilyn nôl",
|
||||
"account.followers": "Dilynwyr",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Diffodd hysbysiadau",
|
||||
"account.mute_short": "Anwybyddu",
|
||||
"account.muted": "Wedi anwybyddu",
|
||||
"account.mutual": "Cydgydnabod",
|
||||
"account.no_bio": "Dim disgrifiad wedi'i gynnig.",
|
||||
"account.open_original_page": "Agor y dudalen wreiddiol",
|
||||
"account.posts": "Postiadau",
|
||||
|
@ -105,7 +105,7 @@
|
|||
"annual_report.summary.here_it_is": "Dyma eich {year} yn gryno:",
|
||||
"annual_report.summary.highlighted_post.by_favourites": "postiad wedi'i ffefrynu fwyaf",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "postiad wedi'i hybu fwyaf",
|
||||
"annual_report.summary.highlighted_post.by_replies": "postiad gyda'r nifer fwyaf o atebion",
|
||||
"annual_report.summary.highlighted_post.by_replies": "postiad gyda'r nifer fwyaf o ymatebion",
|
||||
"annual_report.summary.highlighted_post.possessive": "{name}",
|
||||
"annual_report.summary.most_used_app.most_used_app": "ap a ddefnyddiwyd fwyaf",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "hashnod a ddefnyddiwyd fwyaf",
|
||||
|
@ -226,7 +226,7 @@
|
|||
"confirmations.mute.confirm": "Tewi",
|
||||
"confirmations.redraft.confirm": "Dileu ac ailddrafftio",
|
||||
"confirmations.redraft.message": "Ydych chi wir eisiau'r dileu'r postiad hwn a'i ailddrafftio? Bydd ffefrynnau a hybiau'n cael eu colli, a bydd atebion i'r post gwreiddiol yn mynd yn amddifad.",
|
||||
"confirmations.redraft.title": "Dileu & ailddraftio postiad?",
|
||||
"confirmations.redraft.title": "Dileu ac ailddraftio'r postiad?",
|
||||
"confirmations.reply.confirm": "Ymateb",
|
||||
"confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n siŵr eich bod am barhau?",
|
||||
"confirmations.reply.title": "Trosysgrifo'r postiad?",
|
||||
|
@ -256,7 +256,7 @@
|
|||
"dismissable_banner.explore_tags": "Mae'r hashnodau hyn ar gynnydd y ffedysawd heddiw. Mae hashnodau sy'n cael eu defnyddio gan fwy o bobl amrywiol yn cael eu graddio'n uwch.",
|
||||
"dismissable_banner.public_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl ar y ffedysawd y mae pobl ar {domain} yn eu dilyn.",
|
||||
"domain_block_modal.block": "Blocio gweinydd",
|
||||
"domain_block_modal.block_account_instead": "Blocio @{name} yn ei le",
|
||||
"domain_block_modal.block_account_instead": "Rhwystro @{name} yn lle hynny",
|
||||
"domain_block_modal.they_can_interact_with_old_posts": "Gall pobl o'r gweinydd hwn ryngweithio â'ch hen bostiadau.",
|
||||
"domain_block_modal.they_cant_follow": "All neb o'r gweinydd hwn eich dilyn.",
|
||||
"domain_block_modal.they_wont_know": "Fyddan nhw ddim yn gwybod eu bod wedi cael eu blocio.",
|
||||
|
@ -378,6 +378,8 @@
|
|||
"generic.saved": "Wedi'i Gadw",
|
||||
"getting_started.heading": "Dechrau",
|
||||
"hashtag.admin_moderation": "Agor rhyngwyneb cymedroli #{name}",
|
||||
"hashtag.browse": "Pori postiadau yn #{hashtag}",
|
||||
"hashtag.browse_from_account": "Pori postiadau gan @{name} yn #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "a {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "neu {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "heb {additional}",
|
||||
|
@ -391,6 +393,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {postiad {counter}} other {postiad {counter}}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} postiad} other {{counter} postiad}} heddiw",
|
||||
"hashtag.follow": "Dilyn hashnod",
|
||||
"hashtag.mute": "Tewi #{hashtag}",
|
||||
"hashtag.unfollow": "Dad-ddilyn hashnod",
|
||||
"hashtags.and_other": "…a {count, plural, other {# arall}}",
|
||||
"hints.profiles.followers_may_be_missing": "Mae'n bosibl bod dilynwyr y proffil hwn ar goll.",
|
||||
|
@ -526,7 +529,7 @@
|
|||
"navigation_bar.about": "Ynghylch",
|
||||
"navigation_bar.administration": "Gweinyddiaeth",
|
||||
"navigation_bar.advanced_interface": "Agor mewn rhyngwyneb gwe uwch",
|
||||
"navigation_bar.blocks": "Defnyddwyr wedi eu blocio",
|
||||
"navigation_bar.blocks": "Defnyddwyr wedi'u rhwystro",
|
||||
"navigation_bar.bookmarks": "Nodau Tudalen",
|
||||
"navigation_bar.community_timeline": "Ffrwd leol",
|
||||
"navigation_bar.compose": "Cyfansoddi post newydd",
|
||||
|
@ -537,7 +540,7 @@
|
|||
"navigation_bar.favourites": "Ffefrynnau",
|
||||
"navigation_bar.filters": "Geiriau wedi'u tewi",
|
||||
"navigation_bar.follow_requests": "Ceisiadau dilyn",
|
||||
"navigation_bar.followed_tags": "Hashnodau'n cael eu dilyn",
|
||||
"navigation_bar.followed_tags": "Hashnodau a ddilynir",
|
||||
"navigation_bar.follows_and_followers": "Yn dilyn a dilynwyr",
|
||||
"navigation_bar.lists": "Rhestrau",
|
||||
"navigation_bar.logout": "Allgofnodi",
|
||||
|
@ -582,7 +585,7 @@
|
|||
"notification.moderation_warning.action_none": "Mae eich cyfrif wedi derbyn rhybudd cymedroli.",
|
||||
"notification.moderation_warning.action_sensitive": "Bydd eich postiadau'n cael eu marcio'n sensitif o hyn ymlaen.",
|
||||
"notification.moderation_warning.action_silence": "Mae eich cyfrif wedi'i gyfyngu.",
|
||||
"notification.moderation_warning.action_suspend": "Mae eich cyfrif wedi'i hatal.",
|
||||
"notification.moderation_warning.action_suspend": "Mae eich cyfrif wedi'i atal.",
|
||||
"notification.own_poll": "Mae eich pleidlais wedi dod i ben",
|
||||
"notification.poll": "Mae arolwg y gwnaethoch bleidleisio ynddo wedi dod i ben",
|
||||
"notification.reblog": "Hybodd {name} eich post",
|
||||
|
@ -853,9 +856,9 @@
|
|||
"status.remove_bookmark": "Tynnu nod tudalen",
|
||||
"status.remove_favourite": "Tynnu o'r ffefrynnau",
|
||||
"status.replied_in_thread": "Atebodd mewn edefyn",
|
||||
"status.replied_to": "Wedi ateb {name}",
|
||||
"status.replied_to": "Wedi ymateb i {name}",
|
||||
"status.reply": "Ymateb",
|
||||
"status.replyAll": "Ateb i edefyn",
|
||||
"status.replyAll": "Ymateb i edefyn",
|
||||
"status.report": "Adrodd ar @{name}",
|
||||
"status.sensitive_warning": "Cynnwys sensitif",
|
||||
"status.share": "Rhannu",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Blokér domænet {domain}",
|
||||
"account.block_short": "Bloker",
|
||||
"account.blocked": "Blokeret",
|
||||
"account.blocking": "Blokering",
|
||||
"account.cancel_follow_request": "Annullér anmodning om at følge",
|
||||
"account.copy": "Kopiér link til profil",
|
||||
"account.direct": "Privat omtale @{name}",
|
||||
"account.disable_notifications": "Advisér mig ikke længere, når @{name} poster",
|
||||
"account.domain_blocked": "Domæne blokeret",
|
||||
"account.domain_blocking": "Blokerer domæne",
|
||||
"account.edit_profile": "Redigér profil",
|
||||
"account.enable_notifications": "Advisér mig, når @{name} poster",
|
||||
"account.endorse": "Fremhæv på profil",
|
||||
"account.featured": "Fremhævet",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Indlæg",
|
||||
"account.featured_tags.last_status_at": "Seneste indlæg {date}",
|
||||
"account.featured_tags.last_status_never": "Ingen indlæg",
|
||||
"account.featured_tags.title": "{name}s fremhævede etiketter",
|
||||
"account.follow": "Følg",
|
||||
"account.follow_back": "Følg tilbage",
|
||||
"account.followers": "Følgere",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Følger",
|
||||
"account.following_counter": "{count, plural, one {{counter} følger} other {{counter} følger}}",
|
||||
"account.follows.empty": "Denne bruger følger ikke nogen endnu.",
|
||||
"account.follows_you": "Følger dig",
|
||||
"account.go_to_profile": "Gå til profil",
|
||||
"account.hide_reblogs": "Skjul fremhævelser fra @{name}",
|
||||
"account.in_memoriam": "Til minde om.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Sluk for notifikationer",
|
||||
"account.mute_short": "Skjul",
|
||||
"account.muted": "Skjult",
|
||||
"account.mutual": "Fælles",
|
||||
"account.muting": "Tavsgørelse",
|
||||
"account.mutual": "I følger hinanden",
|
||||
"account.no_bio": "Ingen beskrivelse til rådighed.",
|
||||
"account.open_original_page": "Åbn oprindelig side",
|
||||
"account.posts": "Indlæg",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "Anmeld @{name}",
|
||||
"account.requested": "Afventer godkendelse. Tryk for at annullere følgeanmodning",
|
||||
"account.requested_follow": "{name} har anmodet om at følge dig",
|
||||
"account.requests_to_follow_you": "Anmodninger om at følge dig",
|
||||
"account.share": "Del @{name}s profil",
|
||||
"account.show_reblogs": "Vis fremhævelser fra @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Søgeresultater",
|
||||
"emoji_button.symbols": "Symboler",
|
||||
"emoji_button.travel": "Rejser og steder",
|
||||
"empty_column.account_featured.me": "Du har ikke vist noget endnu. Vidste du, at man kan fremhæve sine indlæg, mest brugte hashtags og endda en vens konti på sin profil?",
|
||||
"empty_column.account_featured.other": "{acct} har ikke vist noget endnu. Vidste du, at man kan fremhæve sine indlæg, mest brugte hashtags og endda en vens konti på sin profil?",
|
||||
"empty_column.account_featured_other.unknown": "Denne konto har ikke fremhævet noget endnu.",
|
||||
"empty_column.account_hides_collections": "Brugeren har valgt ikke at gøre denne information tilgængelig",
|
||||
"empty_column.account_suspended": "Konto suspenderet",
|
||||
"empty_column.account_timeline": "Ingen indlæg her!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Gemt",
|
||||
"getting_started.heading": "Startmenu",
|
||||
"hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}",
|
||||
"hashtag.browse": "Gennemse indlæg i #{hashtag}",
|
||||
"hashtag.browse_from_account": "Gennemse indlæg fra @{name} i #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "og {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "eller {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "uden {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}} i dag",
|
||||
"hashtag.follow": "Følg etiket",
|
||||
"hashtag.mute": "Tavsgør #{hashtag}",
|
||||
"hashtag.unfollow": "Stop med at følge etiket",
|
||||
"hashtags.and_other": "…og {count, plural, one {}other {# flere}}",
|
||||
"hints.profiles.followers_may_be_missing": "Der kan mangle følgere for denne profil.",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "{domain} sperren",
|
||||
"account.block_short": "Blockieren",
|
||||
"account.blocked": "Blockiert",
|
||||
"account.blocking": "Gesperrt",
|
||||
"account.cancel_follow_request": "Follower-Anfrage zurückziehen",
|
||||
"account.copy": "Link zum Profil kopieren",
|
||||
"account.direct": "@{name} privat erwähnen",
|
||||
"account.disable_notifications": "Höre auf mich zu benachrichtigen wenn @{name} etwas postet",
|
||||
"account.domain_blocked": "Domain versteckt",
|
||||
"account.domain_blocking": "Domain gesperrt",
|
||||
"account.edit_profile": "Profil bearbeiten",
|
||||
"account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
|
||||
"account.endorse": "Im Profil empfehlen",
|
||||
"account.featured": "Vorgestellt",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Beiträge",
|
||||
"account.featured_tags.last_status_at": "Letzter Beitrag am {date}",
|
||||
"account.featured_tags.last_status_never": "Keine Beiträge",
|
||||
"account.featured_tags.title": "Von {name} vorgestellte Hashtags",
|
||||
"account.follow": "Folgen",
|
||||
"account.follow_back": "Ebenfalls folgen",
|
||||
"account.followers": "Follower",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Folge ich",
|
||||
"account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
|
||||
"account.follows.empty": "Dieses Profil folgt noch niemandem.",
|
||||
"account.follows_you": "Folgt dir",
|
||||
"account.go_to_profile": "Profil aufrufen",
|
||||
"account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden",
|
||||
"account.in_memoriam": "Zum Andenken.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Benachrichtigungen stummschalten",
|
||||
"account.mute_short": "Stummschalten",
|
||||
"account.muted": "Stummgeschaltet",
|
||||
"account.mutual": "Gegenseitig",
|
||||
"account.muting": "Wird stummgeschaltet",
|
||||
"account.mutual": "Ihr folgt euch",
|
||||
"account.no_bio": "Keine Beschreibung verfügbar.",
|
||||
"account.open_original_page": "Ursprüngliche Seite öffnen",
|
||||
"account.posts": "Beiträge",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "@{name} melden",
|
||||
"account.requested": "Die Genehmigung steht noch aus. Klicke hier, um die Follower-Anfrage zurückzuziehen",
|
||||
"account.requested_follow": "{name} möchte dir folgen",
|
||||
"account.requests_to_follow_you": "Möchte dir folgen",
|
||||
"account.share": "Profil von @{name} teilen",
|
||||
"account.show_reblogs": "Geteilte Beiträge von @{name} anzeigen",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Suchergebnisse",
|
||||
"emoji_button.symbols": "Symbole",
|
||||
"emoji_button.travel": "Reisen & Orte",
|
||||
"empty_column.account_featured.me": "Du hast bisher noch nichts vorgestellt. Wusstest du, dass du deine Beiträge, häufig verwendete Hashtags und sogar andere Profile vorstellen kannst?",
|
||||
"empty_column.account_featured.other": "{acct} hat bisher noch nichts vorgestellt. Wusstest du, dass du deine Beiträge, häufig verwendete Hashtags und sogar andere Profile vorstellen kannst?",
|
||||
"empty_column.account_featured_other.unknown": "Dieses Profil hat bisher noch nichts vorgestellt.",
|
||||
"empty_column.account_hides_collections": "Das Konto hat sich dazu entschieden, diese Information nicht zu veröffentlichen",
|
||||
"empty_column.account_suspended": "Konto gesperrt",
|
||||
"empty_column.account_timeline": "Keine Beiträge vorhanden!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Gespeichert",
|
||||
"getting_started.heading": "Auf gehts!",
|
||||
"hashtag.admin_moderation": "#{name} moderieren",
|
||||
"hashtag.browse": "Beiträge mit #{hashtag} suchen",
|
||||
"hashtag.browse_from_account": "Beiträge von @{name} mit #{hashtag} suchen",
|
||||
"hashtag.column_header.tag_mode.all": "und {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "oder {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "ohne {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}} heute",
|
||||
"hashtag.follow": "Hashtag folgen",
|
||||
"hashtag.mute": "#{hashtag} stummschalten",
|
||||
"hashtag.unfollow": "Hashtag entfolgen",
|
||||
"hashtags.and_other": "… und {count, plural, one{# weiterer} other {# weitere}}",
|
||||
"hints.profiles.followers_may_be_missing": "Möglicherweise werden für dieses Profil nicht alle Follower angezeigt.",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Αντιγραφή συνδέσμου προφίλ",
|
||||
"account.direct": "Ιδιωτική αναφορά @{name}",
|
||||
"account.disable_notifications": "Σταμάτα να με ειδοποιείς όταν δημοσιεύει ο @{name}",
|
||||
"account.domain_blocked": "Ο τομέας αποκλείστηκε",
|
||||
"account.edit_profile": "Επεξεργασία προφίλ",
|
||||
"account.enable_notifications": "Ειδοποίησέ με όταν δημοσιεύει ο @{name}",
|
||||
"account.endorse": "Προβολή στο προφίλ",
|
||||
"account.featured": "Προτεινόμενα",
|
||||
"account.featured.hashtags": "Ετικέτες",
|
||||
"account.featured.posts": "Αναρτήσεις",
|
||||
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
|
||||
"account.featured_tags.last_status_never": "Καμία ανάρτηση",
|
||||
"account.featured_tags.title": "προβεβλημένες ετικέτες του/της {name}",
|
||||
"account.follow": "Ακολούθησε",
|
||||
"account.follow_back": "Ακολούθησε και εσύ",
|
||||
"account.followers": "Ακόλουθοι",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Σίγαση ειδοποιήσεων",
|
||||
"account.mute_short": "Σίγαση",
|
||||
"account.muted": "Αποσιωπημένος/η",
|
||||
"account.mutual": "Αμοιβαίοι",
|
||||
"account.no_bio": "Δεν υπάρχει περιγραφή.",
|
||||
"account.open_original_page": "Ανοικτό",
|
||||
"account.posts": "Τουτ",
|
||||
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}}",
|
||||
"account.unblock": "Άρση αποκλεισμού @{name}",
|
||||
"account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
|
||||
"account.unblock_domain_short": "Άρση αποκλ.",
|
||||
"account.unblock_short": "Άρση αποκλεισμού",
|
||||
"account.unendorse": "Να μην παρέχεται στο προφίλ",
|
||||
"account.unfollow": "Άρση ακολούθησης",
|
||||
|
@ -377,6 +378,8 @@
|
|||
"generic.saved": "Αποθηκεύτηκε",
|
||||
"getting_started.heading": "Ας ξεκινήσουμε",
|
||||
"hashtag.admin_moderation": "Άνοιγμα διεπαφής συντονισμού για το #{name}",
|
||||
"hashtag.browse": "Περιήγηση αναρτήσεων με #{hashtag}",
|
||||
"hashtag.browse_from_account": "Περιήγηση αναρτήσεων από @{name} σε #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "και {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "ή {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "χωρίς {additional}",
|
||||
|
@ -390,6 +393,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}} σήμερα",
|
||||
"hashtag.follow": "Παρακολούθηση ετικέτας",
|
||||
"hashtag.mute": "Σίγαση #{hashtag}",
|
||||
"hashtag.unfollow": "Διακοπή παρακολούθησης ετικέτας",
|
||||
"hashtags.and_other": "…και {count, plural, one {}other {# ακόμη}}",
|
||||
"hints.profiles.followers_may_be_missing": "Μπορεί να λείπουν ακόλουθοι για αυτό το προφίλ.",
|
||||
|
@ -872,7 +876,9 @@
|
|||
"subscribed_languages.target": "Αλλαγή εγγεγραμμένων γλωσσών για {target}",
|
||||
"tabs_bar.home": "Αρχική",
|
||||
"tabs_bar.notifications": "Ειδοποιήσεις",
|
||||
"terms_of_service.effective_as_of": "Ενεργό από {date}",
|
||||
"terms_of_service.title": "Όροι Παροχής Υπηρεσιών",
|
||||
"terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}",
|
||||
"time_remaining.days": "απομένουν {number, plural, one {# ημέρα} other {# ημέρες}}",
|
||||
"time_remaining.hours": "απομένουν {number, plural, one {# ώρα} other {# ώρες}}",
|
||||
"time_remaining.minutes": "απομένουν {number, plural, one {# λεπτό} other {# λεπτά}}",
|
||||
|
@ -903,6 +909,12 @@
|
|||
"video.expand": "Επέκταση βίντεο",
|
||||
"video.fullscreen": "Πλήρης οθόνη",
|
||||
"video.hide": "Απόκρυψη βίντεο",
|
||||
"video.mute": "Σίγαση",
|
||||
"video.pause": "Παύση",
|
||||
"video.play": "Αναπαραγωγή"
|
||||
"video.play": "Αναπαραγωγή",
|
||||
"video.skip_backward": "Παράλειψη πίσω",
|
||||
"video.skip_forward": "Παράλειψη εμπρός",
|
||||
"video.unmute": "Άρση σίγασης",
|
||||
"video.volume_down": "Μείωση έντασης",
|
||||
"video.volume_up": "Αύξηση έντασης"
|
||||
}
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Copy link to profile",
|
||||
"account.direct": "Privately mention @{name}",
|
||||
"account.disable_notifications": "Stop notifying me when @{name} posts",
|
||||
"account.domain_blocked": "Domain blocked",
|
||||
"account.edit_profile": "Edit profile",
|
||||
"account.enable_notifications": "Notify me when @{name} posts",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.featured_tags.title": "{name}'s featured hashtags",
|
||||
"account.follow": "Follow",
|
||||
"account.follow_back": "Follow back",
|
||||
"account.followers": "Followers",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Mute notifications",
|
||||
"account.mute_short": "Mute",
|
||||
"account.muted": "Muted",
|
||||
"account.mutual": "Mutual",
|
||||
"account.no_bio": "No description provided.",
|
||||
"account.open_original_page": "Open original page",
|
||||
"account.posts": "Posts",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Block domain {domain}",
|
||||
"account.block_short": "Block",
|
||||
"account.blocked": "Blocked",
|
||||
"account.blocking": "Blocking",
|
||||
"account.cancel_follow_request": "Cancel follow",
|
||||
"account.copy": "Copy link to profile",
|
||||
"account.direct": "Privately mention @{name}",
|
||||
"account.disable_notifications": "Stop notifying me when @{name} posts",
|
||||
"account.domain_blocked": "Domain blocked",
|
||||
"account.domain_blocking": "Blocking domain",
|
||||
"account.edit_profile": "Edit profile",
|
||||
"account.enable_notifications": "Notify me when @{name} posts",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.featured": "Featured",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Posts",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.featured_tags.title": "{name}'s featured hashtags",
|
||||
"account.follow": "Follow",
|
||||
"account.follow_back": "Follow back",
|
||||
"account.followers": "Followers",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Following",
|
||||
"account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
|
||||
"account.follows.empty": "This user doesn't follow anyone yet.",
|
||||
"account.follows_you": "Follows you",
|
||||
"account.go_to_profile": "Go to profile",
|
||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
|
@ -52,14 +56,17 @@
|
|||
"account.mute_notifications_short": "Mute notifications",
|
||||
"account.mute_short": "Mute",
|
||||
"account.muted": "Muted",
|
||||
"account.mutual": "Mutual",
|
||||
"account.muting": "Muting",
|
||||
"account.mutual": "You follow each other",
|
||||
"account.no_bio": "No description provided.",
|
||||
"account.open_original_page": "Open original page",
|
||||
"account.posts": "Posts",
|
||||
"account.posts_with_replies": "Posts and replies",
|
||||
"account.remove_from_followers": "Remove {name} from followers",
|
||||
"account.report": "Report @{name}",
|
||||
"account.requested": "Awaiting approval. Click to cancel follow request",
|
||||
"account.requested_follow": "{name} has requested to follow you",
|
||||
"account.requests_to_follow_you": "Requests to follow you",
|
||||
"account.share": "Share @{name}'s profile",
|
||||
"account.show_reblogs": "Show boosts from @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
||||
|
@ -227,6 +234,9 @@
|
|||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.",
|
||||
"confirmations.redraft.title": "Delete & redraft post?",
|
||||
"confirmations.remove_from_followers.confirm": "Remove follower",
|
||||
"confirmations.remove_from_followers.message": "{name} will stop following you. Are you sure you want to proceed?",
|
||||
"confirmations.remove_from_followers.title": "Remove follower?",
|
||||
"confirmations.reply.confirm": "Reply",
|
||||
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||
"confirmations.reply.title": "Overwrite post?",
|
||||
|
@ -294,6 +304,9 @@
|
|||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?",
|
||||
"empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?",
|
||||
"empty_column.account_featured_other.unknown": "This account has not featured anything yet.",
|
||||
"empty_column.account_hides_collections": "This user has chosen to not make this information available",
|
||||
"empty_column.account_suspended": "Account suspended",
|
||||
"empty_column.account_timeline": "No posts here!",
|
||||
|
@ -378,6 +391,8 @@
|
|||
"generic.saved": "Saved",
|
||||
"getting_started.heading": "Getting started",
|
||||
"hashtag.admin_moderation": "Open moderation interface for #{name}",
|
||||
"hashtag.browse": "Browse posts in #{hashtag}",
|
||||
"hashtag.browse_from_account": "Browse posts from @{name} in #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
|
@ -391,6 +406,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"hashtag.mute": "Mute #{hashtag}",
|
||||
"hashtag.unfollow": "Unfollow hashtag",
|
||||
"hashtags.and_other": "…and {count, plural, other {# more}}",
|
||||
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
|
||||
|
|
|
@ -23,13 +23,13 @@
|
|||
"account.copy": "Kopii ligilon al profilo",
|
||||
"account.direct": "Private mencii @{name}",
|
||||
"account.disable_notifications": "Ĉesu sciigi min kiam @{name} afiŝas",
|
||||
"account.domain_blocked": "Domajno blokita",
|
||||
"account.edit_profile": "Redakti la profilon",
|
||||
"account.enable_notifications": "Sciigu min kiam @{name} afiŝos",
|
||||
"account.endorse": "Prezenti ĉe via profilo",
|
||||
"account.featured.hashtags": "Kradvortoj",
|
||||
"account.featured.posts": "Afiŝoj",
|
||||
"account.featured_tags.last_status_at": "Lasta afîŝo je {date}",
|
||||
"account.featured_tags.last_status_never": "Neniu afiŝo",
|
||||
"account.featured_tags.title": "Rekomendataj kradvortoj de {name}",
|
||||
"account.follow": "Sekvi",
|
||||
"account.follow_back": "Sekvu reen",
|
||||
"account.followers": "Sekvantoj",
|
||||
|
@ -52,7 +52,6 @@
|
|||
"account.mute_notifications_short": "Silentigu sciigojn",
|
||||
"account.mute_short": "Silentigu",
|
||||
"account.muted": "Silentigita",
|
||||
"account.mutual": "Reciproka",
|
||||
"account.no_bio": "Neniu priskribo estas provizita.",
|
||||
"account.open_original_page": "Malfermi la originalan paĝon",
|
||||
"account.posts": "Afiŝoj",
|
||||
|
@ -65,6 +64,7 @@
|
|||
"account.statuses_counter": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}}",
|
||||
"account.unblock": "Malbloki @{name}",
|
||||
"account.unblock_domain": "Malbloki la domajnon {domain}",
|
||||
"account.unblock_domain_short": "Malbloki",
|
||||
"account.unblock_short": "Malbloki",
|
||||
"account.unendorse": "Ne plu rekomendi ĉe la profilo",
|
||||
"account.unfollow": "Ĉesi sekvi",
|
||||
|
@ -905,6 +905,8 @@
|
|||
"video.expand": "Pligrandigi la videon",
|
||||
"video.fullscreen": "Igi plenekrana",
|
||||
"video.hide": "Kaŝu la filmeton",
|
||||
"video.mute": "Silentigi",
|
||||
"video.pause": "Paŭzigi",
|
||||
"video.play": "Ekigi"
|
||||
"video.play": "Ekigi",
|
||||
"video.unmute": "Ne plu silentigi"
|
||||
}
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Bloquear dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
"account.blocked": "Bloqueado",
|
||||
"account.blocking": "Bloqueo",
|
||||
"account.cancel_follow_request": "Dejar de seguir",
|
||||
"account.copy": "Copiar enlace al perfil",
|
||||
"account.direct": "Mención privada a @{name}",
|
||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} envíe mensajes",
|
||||
"account.domain_blocked": "Dominio bloqueado",
|
||||
"account.domain_blocking": "Bloqueando dominio",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Notificarme cuando @{name} envíe mensajes",
|
||||
"account.endorse": "Destacar en el perfil",
|
||||
"account.featured": "Destacados",
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured.posts": "Mensajes",
|
||||
"account.featured_tags.last_status_at": "Último mensaje: {date}",
|
||||
"account.featured_tags.last_status_never": "Sin mensajes",
|
||||
"account.featured_tags.title": "Etiquetas destacadas de {name}",
|
||||
"account.follow": "Seguir",
|
||||
"account.follow_back": "Seguir",
|
||||
"account.followers": "Seguidores",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Siguiendo",
|
||||
"account.following_counter": "{count, plural, one {siguiendo a {counter}} other {siguiendo a {counter}}}",
|
||||
"account.follows.empty": "Todavía este usuario no sigue a nadie.",
|
||||
"account.follows_you": "Te sigue",
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar adhesiones de @{name}",
|
||||
"account.in_memoriam": "Cuenta conmemorativa.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Silenciar notificaciones",
|
||||
"account.mute_short": "Silenciar",
|
||||
"account.muted": "Silenciado",
|
||||
"account.mutual": "Seguimiento mutuo",
|
||||
"account.muting": "Silenciada",
|
||||
"account.mutual": "Se siguen mutuamente",
|
||||
"account.no_bio": "Sin descripción provista.",
|
||||
"account.open_original_page": "Abrir página original",
|
||||
"account.posts": "Mensajes",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "Denunciar a @{name}",
|
||||
"account.requested": "Esperando aprobación. Hacé clic para cancelar la solicitud de seguimiento",
|
||||
"account.requested_follow": "{name} solicitó seguirte",
|
||||
"account.requests_to_follow_you": "Solicita seguirte",
|
||||
"account.share": "Compartir el perfil de @{name}",
|
||||
"account.show_reblogs": "Mostrar adhesiones de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Resultados de búsqueda",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viajes y lugares",
|
||||
"empty_column.account_featured.me": "Todavía no destacaste nada. ¿Sabías que en tu perfil podés destacar tus publicaciones, las etiquetas que más usás e incluso las cuentas de tus contactos?",
|
||||
"empty_column.account_featured.other": "{acct} todavía no destacó nada. ¿Sabías que en tu perfil podés destacar tus publicaciones, las etiquetas que más usás e incluso las cuentas de tus contactos?",
|
||||
"empty_column.account_featured_other.unknown": "Esta cuenta todavía no destacó nada.",
|
||||
"empty_column.account_hides_collections": "Este usuario eligió no publicar esta información",
|
||||
"empty_column.account_suspended": "Cuenta suspendida",
|
||||
"empty_column.account_timeline": "¡No hay mensajes acá!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Guardado",
|
||||
"getting_started.heading": "Inicio de Mastodon",
|
||||
"hashtag.admin_moderation": "Abrir interface de moderación para #{name}",
|
||||
"hashtag.browse": "Ver publicaciones con #{hashtag}",
|
||||
"hashtag.browse_from_account": "Ver publicaciones de @{name} con #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "y {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "o {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "sin {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}} hoy",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfollow": "Dejar de seguir etiqueta",
|
||||
"hashtags.and_other": "…y {count, plural, other {# más}}",
|
||||
"hints.profiles.followers_may_be_missing": "Es posible que falten seguidores de este perfil.",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Bloquear dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
"account.blocked": "Bloqueado",
|
||||
"account.blocking": "Bloqueando",
|
||||
"account.cancel_follow_request": "Cancelar seguimiento",
|
||||
"account.copy": "Copiar enlace al perfil",
|
||||
"account.direct": "Mención privada @{name}",
|
||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo",
|
||||
"account.domain_blocked": "Dominio oculto",
|
||||
"account.domain_blocking": "Bloqueando dominio",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
||||
"account.endorse": "Destacar en mi perfil",
|
||||
"account.featured": "Destacado",
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured.posts": "Publicaciones",
|
||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||
"account.featured_tags.last_status_never": "Sin publicaciones",
|
||||
"account.featured_tags.title": "Etiquetas destacadas de {name}",
|
||||
"account.follow": "Seguir",
|
||||
"account.follow_back": "Seguir también",
|
||||
"account.followers": "Seguidores",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Siguiendo",
|
||||
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
|
||||
"account.follows.empty": "Este usuario no sigue a nadie todavía.",
|
||||
"account.follows_you": "Te sigue",
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
||||
"account.in_memoriam": "En memoria.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Silenciar notificaciones",
|
||||
"account.mute_short": "Silenciar",
|
||||
"account.muted": "Silenciado",
|
||||
"account.mutual": "Mutuo",
|
||||
"account.muting": "Silenciando",
|
||||
"account.mutual": "Se siguen el uno al otro",
|
||||
"account.no_bio": "Sin biografía.",
|
||||
"account.open_original_page": "Abrir página original",
|
||||
"account.posts": "Publicaciones",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "Denunciar a @{name}",
|
||||
"account.requested": "Esperando aprobación. Haz clic para cancelar la solicitud de seguimiento",
|
||||
"account.requested_follow": "{name} ha solicitado seguirte",
|
||||
"account.requests_to_follow_you": "Solicita seguirte",
|
||||
"account.share": "Compartir el perfil de @{name}",
|
||||
"account.show_reblogs": "Mostrar impulsos de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Resultados de búsqueda",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viajes y lugares",
|
||||
"empty_column.account_featured.me": "No has destacado nada todavía. ¿Sabías que puedes destacar tus publicaciones, las etiquetas que más usas e incluso las cuentas de tus amigos en tu perfil?",
|
||||
"empty_column.account_featured.other": "{acct} no ha destacado nada todavía. ¿Sabías que puedes destacar tus publicaciones, las etiquetas que más usas e incluso las cuentas de tus amigos en tu perfil?",
|
||||
"empty_column.account_featured_other.unknown": "Esta cuenta no ha destacado nada todavía.",
|
||||
"empty_column.account_hides_collections": "Este usuario ha elegido no hacer disponible esta información",
|
||||
"empty_column.account_suspended": "Cuenta suspendida",
|
||||
"empty_column.account_timeline": "¡No hay publicaciones aquí!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Guardado",
|
||||
"getting_started.heading": "Primeros pasos",
|
||||
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
|
||||
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
|
||||
"hashtag.browse_from_account": "Explorar publicaciones de @{name} en #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "y {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "o {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "sin {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfollow": "Dejar de seguir etiqueta",
|
||||
"hashtags.and_other": "…y {count, plural, other {# más}}",
|
||||
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",
|
||||
|
@ -910,8 +922,8 @@
|
|||
"video.pause": "Pausar",
|
||||
"video.play": "Reproducir",
|
||||
"video.skip_backward": "Saltar atrás",
|
||||
"video.skip_forward": "Adelantar",
|
||||
"video.skip_forward": "Saltar adelante",
|
||||
"video.unmute": "Dejar de silenciar",
|
||||
"video.volume_down": "Bajar volumen",
|
||||
"video.volume_up": "Subir volumen"
|
||||
"video.volume_down": "Bajar el volumen",
|
||||
"video.volume_up": "Subir el volumen"
|
||||
}
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Bloquear dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
"account.blocked": "Bloqueado",
|
||||
"account.blocking": "Bloqueando",
|
||||
"account.cancel_follow_request": "Retirar solicitud de seguimiento",
|
||||
"account.copy": "Copiar enlace al perfil",
|
||||
"account.direct": "Mención privada a @{name}",
|
||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo",
|
||||
"account.domain_blocked": "Dominio bloqueado",
|
||||
"account.domain_blocking": "Bloqueando dominio",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
||||
"account.endorse": "Destacar en el perfil",
|
||||
"account.featured": "Destacado",
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured.posts": "Publicaciones",
|
||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||
"account.featured_tags.last_status_never": "Sin publicaciones",
|
||||
"account.featured_tags.title": "Etiquetas destacadas de {name}",
|
||||
"account.follow": "Seguir",
|
||||
"account.follow_back": "Seguir también",
|
||||
"account.followers": "Seguidores",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Siguiendo",
|
||||
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
|
||||
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
|
||||
"account.follows_you": "Te sigue",
|
||||
"account.go_to_profile": "Ir al perfil",
|
||||
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
||||
"account.in_memoriam": "Cuenta conmemorativa.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Silenciar notificaciones",
|
||||
"account.mute_short": "Silenciar",
|
||||
"account.muted": "Silenciado",
|
||||
"account.mutual": "Mutuo",
|
||||
"account.muting": "Silenciando",
|
||||
"account.mutual": "Os seguís mutuamente",
|
||||
"account.no_bio": "Sin biografía.",
|
||||
"account.open_original_page": "Abrir página original",
|
||||
"account.posts": "Publicaciones",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "Reportar a @{name}",
|
||||
"account.requested": "Esperando aprobación. Haz clic para cancelar la solicitud de seguimiento",
|
||||
"account.requested_follow": "{name} ha solicitado seguirte",
|
||||
"account.requests_to_follow_you": "Solicita seguirte",
|
||||
"account.share": "Compartir el perfil de @{name}",
|
||||
"account.show_reblogs": "Mostrar impulsos de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Resultados de búsqueda",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viajes y lugares",
|
||||
"empty_column.account_featured.me": "Aún no has destacado nada. ¿Sabías que puedes destacar tus publicaciones, las etiquetas que más utilizas e incluso las cuentas de tus amigos en tu perfil?",
|
||||
"empty_column.account_featured.other": "{acct} no ha destacado nada aún. ¿Sabías que puedes destacar tus publicaciones, las etiquetas que más utilizas e incluso las cuentas de tus amigos en tu perfil?",
|
||||
"empty_column.account_featured_other.unknown": "Esta cuenta aún no ha destacado nada.",
|
||||
"empty_column.account_hides_collections": "Este usuario ha decidido no mostrar esta información",
|
||||
"empty_column.account_suspended": "Cuenta suspendida",
|
||||
"empty_column.account_timeline": "¡No hay publicaciones aquí!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Guardado",
|
||||
"getting_started.heading": "Primeros pasos",
|
||||
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
|
||||
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
|
||||
"hashtag.browse_from_account": "Explorar publicaciones de @{name} en #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "y {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "o {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "sin {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfollow": "Dejar de seguir etiqueta",
|
||||
"hashtags.and_other": "…y {count, plural, other {# más}}",
|
||||
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Kopeeri profiili link",
|
||||
"account.direct": "Maini privaatselt @{name}",
|
||||
"account.disable_notifications": "Peata teavitused @{name} postitustest",
|
||||
"account.domain_blocked": "Domeen peidetud",
|
||||
"account.edit_profile": "Muuda profiili",
|
||||
"account.enable_notifications": "Teavita mind @{name} postitustest",
|
||||
"account.endorse": "Too profiilil esile",
|
||||
"account.featured_tags.last_status_at": "Viimane postitus {date}",
|
||||
"account.featured_tags.last_status_never": "Postitusi pole",
|
||||
"account.featured_tags.title": "{name} esiletõstetud sildid",
|
||||
"account.follow": "Jälgi",
|
||||
"account.follow_back": "Jälgi vastu",
|
||||
"account.followers": "Jälgijad",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Vaigista teavitused",
|
||||
"account.mute_short": "Vaigista",
|
||||
"account.muted": "Vaigistatud",
|
||||
"account.mutual": "Jälgite",
|
||||
"account.no_bio": "Kirjeldust pole lisatud.",
|
||||
"account.open_original_page": "Ava algne leht",
|
||||
"account.posts": "Postitused",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Kopiatu profilerako esteka",
|
||||
"account.direct": "Aipatu pribatuki @{name}",
|
||||
"account.disable_notifications": "Utzi jakinarazteari @{name} erabiltzaileak argitaratzean",
|
||||
"account.domain_blocked": "Ezkutatutako domeinua",
|
||||
"account.edit_profile": "Editatu profila",
|
||||
"account.enable_notifications": "Jakinarazi @{name} erabiltzaileak argitaratzean",
|
||||
"account.endorse": "Nabarmendu profilean",
|
||||
"account.featured_tags.last_status_at": "Azken bidalketa {date} datan",
|
||||
"account.featured_tags.last_status_never": "Bidalketarik ez",
|
||||
"account.featured_tags.title": "{name} erabiltzailearen nabarmendutako traolak",
|
||||
"account.follow": "Jarraitu",
|
||||
"account.follow_back": "Jarraitu bueltan",
|
||||
"account.followers": "Jarraitzaileak",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Mututu jakinarazpenak",
|
||||
"account.mute_short": "Mututu",
|
||||
"account.muted": "Mutututa",
|
||||
"account.mutual": "Elkarrekikoa",
|
||||
"account.no_bio": "Ez da deskribapenik eman.",
|
||||
"account.open_original_page": "Ireki jatorrizko orria",
|
||||
"account.posts": "Bidalketa",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "رونوشت از پیوند به نمایه",
|
||||
"account.direct": "اشارهٔ خصوصی به @{name}",
|
||||
"account.disable_notifications": "آگاه کردن من هنگام فرستههای @{name} را متوقّف کن",
|
||||
"account.domain_blocked": "دامنه مسدود شد",
|
||||
"account.edit_profile": "ویرایش نمایه",
|
||||
"account.enable_notifications": "هنگام فرستههای @{name} مرا آگاه کن",
|
||||
"account.endorse": "معرّفی در نمایه",
|
||||
"account.featured_tags.last_status_at": "آخرین فرسته در {date}",
|
||||
"account.featured_tags.last_status_never": "بدون فرسته",
|
||||
"account.featured_tags.title": "برچسبهای برگزیدهٔ {name}",
|
||||
"account.follow": "پیگرفتن",
|
||||
"account.follow_back": "دنبال کردن متقابل",
|
||||
"account.followers": "پیگیرندگان",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "خموشی آگاهیها",
|
||||
"account.mute_short": "خموشی",
|
||||
"account.muted": "خموش",
|
||||
"account.mutual": "دوطرفه",
|
||||
"account.no_bio": "شرحی فراهم نشده.",
|
||||
"account.open_original_page": "گشودن صفحهٔ اصلی",
|
||||
"account.posts": "فرسته",
|
||||
|
@ -65,6 +62,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
|
||||
"account.unblock": "رفع مسدودیت @{name}",
|
||||
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
|
||||
"account.unblock_domain_short": "آنبلاک",
|
||||
"account.unblock_short": "رفع مسدودیت",
|
||||
"account.unendorse": "معرّفی نکردن در نمایه",
|
||||
"account.unfollow": "پینگرفتن",
|
||||
|
|
|
@ -19,17 +19,19 @@
|
|||
"account.block_domain": "Estä verkkotunnus {domain}",
|
||||
"account.block_short": "Estä",
|
||||
"account.blocked": "Estetty",
|
||||
"account.blocking": "Estetty",
|
||||
"account.cancel_follow_request": "Peruuta seurantapyyntö",
|
||||
"account.copy": "Kopioi linkki profiiliin",
|
||||
"account.direct": "Mainitse @{name} yksityisesti",
|
||||
"account.disable_notifications": "Lopeta ilmoittamasta minulle, kun @{name} julkaisee",
|
||||
"account.domain_blocked": "Verkkotunnus estetty",
|
||||
"account.domain_blocking": "Estetty verkkotunnus",
|
||||
"account.edit_profile": "Muokkaa profiilia",
|
||||
"account.enable_notifications": "Ilmoita minulle, kun @{name} julkaisee",
|
||||
"account.endorse": "Suosittele profiilissasi",
|
||||
"account.featured.hashtags": "Aihetunnisteet",
|
||||
"account.featured.posts": "Julkaisut",
|
||||
"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} suosittelemat aihetunnisteet",
|
||||
"account.follow": "Seuraa",
|
||||
"account.follow_back": "Seuraa takaisin",
|
||||
"account.followers": "Seuraajat",
|
||||
|
@ -38,6 +40,7 @@
|
|||
"account.following": "Seurattavat",
|
||||
"account.following_counter": "{count, plural, one {{counter} seurattava} other {{counter} seurattavaa}}",
|
||||
"account.follows.empty": "Tämä käyttäjä ei vielä seuraa ketään.",
|
||||
"account.follows_you": "Seuraa sinua",
|
||||
"account.go_to_profile": "Siirry profiiliin",
|
||||
"account.hide_reblogs": "Piilota käyttäjän @{name} tehostukset",
|
||||
"account.in_memoriam": "Muistoissamme.",
|
||||
|
@ -52,6 +55,7 @@
|
|||
"account.mute_notifications_short": "Mykistä ilmoitukset",
|
||||
"account.mute_short": "Mykistä",
|
||||
"account.muted": "Mykistetty",
|
||||
"account.muting": "Hiljennetty",
|
||||
"account.mutual": "Seuraatte toisianne",
|
||||
"account.no_bio": "Kuvausta ei ole annettu.",
|
||||
"account.open_original_page": "Avaa alkuperäinen sivu",
|
||||
|
@ -60,6 +64,7 @@
|
|||
"account.report": "Raportoi @{name}",
|
||||
"account.requested": "Odottaa hyväksyntää. Peruuta seurantapyyntö napsauttamalla",
|
||||
"account.requested_follow": "{name} on pyytänyt lupaa seurata sinua",
|
||||
"account.requests_to_follow_you": "Pyynnöt seurata sinua",
|
||||
"account.share": "Jaa käyttäjän @{name} profiili",
|
||||
"account.show_reblogs": "Näytä käyttäjän @{name} tehostukset",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} julkaisu} other {{counter} julkaisua}}",
|
||||
|
@ -240,7 +245,7 @@
|
|||
"conversation.mark_as_read": "Merkitse luetuksi",
|
||||
"conversation.open": "Näytä keskustelu",
|
||||
"conversation.with": "{names} kanssa",
|
||||
"copy_icon_button.copied": "Sisältö kopioitiin leikepöydälle",
|
||||
"copy_icon_button.copied": "Kopioitu leikepöydälle",
|
||||
"copypaste.copied": "Kopioitu",
|
||||
"copypaste.copy_to_clipboard": "Kopioi leikepöydälle",
|
||||
"directory.federated": "Tunnetusta fediversumista",
|
||||
|
@ -378,6 +383,8 @@
|
|||
"generic.saved": "Tallennettu",
|
||||
"getting_started.heading": "Näin pääset alkuun",
|
||||
"hashtag.admin_moderation": "Avaa tunnisteen #{name} moderointinäkymä",
|
||||
"hashtag.browse": "Selaa julkaisuja tunnisteella #{hashtag}",
|
||||
"hashtag.browse_from_account": "Selaa julkaisuja käyttäjältä @{name} tunnisteella #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "ja {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "tai {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "ilman {additional}",
|
||||
|
@ -391,6 +398,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one{{counter} julkaisu} other {{counter} julkaisua}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} julkaisu} other {{counter} julkaisua}} tänään",
|
||||
"hashtag.follow": "Seuraa aihetunnistetta",
|
||||
"hashtag.mute": "Mykistä #{hashtag}",
|
||||
"hashtag.unfollow": "Lopeta aihetunnisteen seuraaminen",
|
||||
"hashtags.and_other": "…ja {count, plural, other {# lisää}}",
|
||||
"hints.profiles.followers_may_be_missing": "Tämän profiilin seuraajia saattaa puuttua.",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "I-sipi ang kawing sa profile",
|
||||
"account.direct": "Palihim banggitin si @{name}",
|
||||
"account.disable_notifications": "I-tigil ang pagpapaalam sa akin tuwing nagpopost si @{name}",
|
||||
"account.domain_blocked": "Hinadlangan ang domain",
|
||||
"account.edit_profile": "Baguhin ang profile",
|
||||
"account.enable_notifications": "Ipaalam sa akin kapag nag-post si @{name}",
|
||||
"account.endorse": "I-tampok sa profile",
|
||||
"account.featured_tags.last_status_at": "Huling post noong {date}",
|
||||
"account.featured_tags.last_status_never": "Walang mga post",
|
||||
"account.featured_tags.title": "Nakatampok na hashtag ni {name}",
|
||||
"account.follow": "Sundan",
|
||||
"account.follow_back": "Sundan pabalik",
|
||||
"account.followers": "Mga tagasunod",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "I-mute ang mga abiso",
|
||||
"account.mute_short": "I-mute",
|
||||
"account.muted": "Naka-mute",
|
||||
"account.mutual": "Ka-mutual",
|
||||
"account.no_bio": "Walang nakalaan na paglalarawan.",
|
||||
"account.open_original_page": "Buksan ang pinagmulang pahina",
|
||||
"account.posts": "Mga post",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "Banna økisnavnið {domain}",
|
||||
"account.block_short": "Blokera",
|
||||
"account.blocked": "Bannað/ur",
|
||||
"account.blocking": "Banni",
|
||||
"account.cancel_follow_request": "Strika fylgjaraumbøn",
|
||||
"account.copy": "Avrita leinki til vangan",
|
||||
"account.direct": "Umrøð @{name} privat",
|
||||
"account.disable_notifications": "Ikki boða mær frá, tá @{name} skrivar",
|
||||
"account.domain_blocked": "Økisnavn bannað",
|
||||
"account.domain_blocking": "Banni økisnavn",
|
||||
"account.edit_profile": "Broyt vanga",
|
||||
"account.enable_notifications": "Boða mær frá, tá @{name} skrivar",
|
||||
"account.endorse": "Víst á vangamyndini",
|
||||
"account.featured": "Tikin fram",
|
||||
"account.featured.hashtags": "Frámerki",
|
||||
"account.featured.posts": "Postar",
|
||||
"account.featured_tags.last_status_at": "Seinasta strongur skrivaður {date}",
|
||||
"account.featured_tags.last_status_never": "Einki uppslag",
|
||||
"account.featured_tags.title": "Tvíkrossar hjá {name}",
|
||||
"account.follow": "Fylg",
|
||||
"account.follow_back": "Fylg aftur",
|
||||
"account.followers": "Fylgjarar",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "Fylgir",
|
||||
"account.following_counter": "{count, plural, one {{counter} fylgir} other {{counter} fylgja}}",
|
||||
"account.follows.empty": "Hesin brúkari fylgir ongum enn.",
|
||||
"account.follows_you": "Fylgir tær",
|
||||
"account.go_to_profile": "Far til vanga",
|
||||
"account.hide_reblogs": "Fjal lyft frá @{name}",
|
||||
"account.in_memoriam": "In memoriam.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "Sløkk fráboðanir",
|
||||
"account.mute_short": "Doyv",
|
||||
"account.muted": "Sløkt/ur",
|
||||
"account.mutual": "Sínamillum",
|
||||
"account.muting": "Doyvir",
|
||||
"account.mutual": "Tit fylgja hvønn annan",
|
||||
"account.no_bio": "Lýsing vantar.",
|
||||
"account.open_original_page": "Opna upprunasíðuna",
|
||||
"account.posts": "Uppsløg",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "Melda @{name}",
|
||||
"account.requested": "Bíðar eftir góðkenning. Trýst fyri at angra umbønina",
|
||||
"account.requested_follow": "{name} hevur biðið um at fylgja tær",
|
||||
"account.requests_to_follow_you": "Umbønir um at fylgja tær",
|
||||
"account.share": "Deil vanga @{name}'s",
|
||||
"account.show_reblogs": "Vís lyft frá @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} postur} other {{counter} postar}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "Leitiúrslit",
|
||||
"emoji_button.symbols": "Ímyndir",
|
||||
"emoji_button.travel": "Ferðing og støð",
|
||||
"empty_column.account_featured.me": "Tú hevur ikki tikið nakað fram enn. Visti tú, at tú kanst taka fram egnar postar, frámerki, tú brúkar mest, og sjálvt kontur hjá vinum tínum á vangan hjá tær?",
|
||||
"empty_column.account_featured.other": "{acct} hevur ikki tikið nakað fram enn. Visti tú, at tú kanst taka fram tínar postar, frámerki, tú brúkar mest, og sjálvt kontur hjá vinum tínum á vangan hjá tær?",
|
||||
"empty_column.account_featured_other.unknown": "Hendan kontan hevur ikki tikið nakað fram enn.",
|
||||
"empty_column.account_hides_collections": "Hesin brúkarin hevur valt, at hesar upplýsingarnar ikki skulu vera tøkar",
|
||||
"empty_column.account_suspended": "Kontan gjørd óvirkin",
|
||||
"empty_column.account_timeline": "Einki uppslag her!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "Goymt",
|
||||
"getting_started.heading": "At byrja",
|
||||
"hashtag.admin_moderation": "Lat umsjónarmarkamót upp fyri #{name}",
|
||||
"hashtag.browse": "Blaða gjøgnum postar í #{hashtag}",
|
||||
"hashtag.browse_from_account": "Blaða gjøgnum postar frá @{name} í #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "og {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "ella {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "uttan {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} postur} other {{counter} postar}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} postur} other {{counter} postar}} í dag",
|
||||
"hashtag.follow": "Fylg frámerki",
|
||||
"hashtag.mute": "Doyv @#{hashtag}",
|
||||
"hashtag.unfollow": "Gevst at fylgja frámerki",
|
||||
"hashtags.and_other": "…og {count, plural, other {# afturat}}",
|
||||
"hints.profiles.followers_may_be_missing": "Fylgjarar hjá hesum vanganum kunnu mangla.",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Copier le lien vers le profil",
|
||||
"account.direct": "Mention privée @{name}",
|
||||
"account.disable_notifications": "Ne plus me notifier quand @{name} publie",
|
||||
"account.domain_blocked": "Domaine bloqué",
|
||||
"account.edit_profile": "Modifier le profil",
|
||||
"account.enable_notifications": "Me notifier quand @{name} publie",
|
||||
"account.endorse": "Inclure sur profil",
|
||||
"account.featured": "En vedette",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Messages",
|
||||
"account.featured_tags.last_status_at": "Dernière publication {date}",
|
||||
"account.featured_tags.last_status_never": "Aucune publication",
|
||||
"account.featured_tags.title": "Hashtags inclus de {name}",
|
||||
"account.follow": "Suivre",
|
||||
"account.follow_back": "Suivre en retour",
|
||||
"account.followers": "abonné·e·s",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Rendre les notifications muettes",
|
||||
"account.mute_short": "Rendre muet",
|
||||
"account.muted": "Masqué·e",
|
||||
"account.mutual": "Mutuel",
|
||||
"account.no_bio": "Description manquante.",
|
||||
"account.open_original_page": "Ouvrir la page d'origine",
|
||||
"account.posts": "Publications",
|
||||
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} message} other {{counter} messages}}",
|
||||
"account.unblock": "Débloquer @{name}",
|
||||
"account.unblock_domain": "Débloquer le domaine {domain}",
|
||||
"account.unblock_domain_short": "Débloquer",
|
||||
"account.unblock_short": "Débloquer",
|
||||
"account.unendorse": "Ne pas inclure sur profil",
|
||||
"account.unfollow": "Ne plus suivre",
|
||||
|
@ -905,6 +906,10 @@
|
|||
"video.expand": "Agrandir la vidéo",
|
||||
"video.fullscreen": "Plein écran",
|
||||
"video.hide": "Masquer la vidéo",
|
||||
"video.mute": "Couper le son",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Lecture"
|
||||
"video.play": "Lecture",
|
||||
"video.unmute": "Rétablir le son",
|
||||
"video.volume_down": "Baisser le volume",
|
||||
"video.volume_up": "Augmenter le volume"
|
||||
}
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Copier le lien vers le profil",
|
||||
"account.direct": "Mention privée @{name}",
|
||||
"account.disable_notifications": "Ne plus me notifier quand @{name} publie quelque chose",
|
||||
"account.domain_blocked": "Domaine bloqué",
|
||||
"account.edit_profile": "Modifier le profil",
|
||||
"account.enable_notifications": "Me notifier quand @{name} publie quelque chose",
|
||||
"account.endorse": "Recommander sur votre profil",
|
||||
"account.featured": "En vedette",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Messages",
|
||||
"account.featured_tags.last_status_at": "Dernier message le {date}",
|
||||
"account.featured_tags.last_status_never": "Aucun message",
|
||||
"account.featured_tags.title": "Les hashtags en vedette de {name}",
|
||||
"account.follow": "Suivre",
|
||||
"account.follow_back": "Suivre en retour",
|
||||
"account.followers": "Abonné·e·s",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Désactiver les notifications",
|
||||
"account.mute_short": "Mettre en sourdine",
|
||||
"account.muted": "Masqué·e",
|
||||
"account.mutual": "Mutuel",
|
||||
"account.no_bio": "Aucune description fournie.",
|
||||
"account.open_original_page": "Ouvrir la page d'origine",
|
||||
"account.posts": "Messages",
|
||||
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} message} other {{counter} messages}}",
|
||||
"account.unblock": "Débloquer @{name}",
|
||||
"account.unblock_domain": "Débloquer le domaine {domain}",
|
||||
"account.unblock_domain_short": "Débloquer",
|
||||
"account.unblock_short": "Débloquer",
|
||||
"account.unendorse": "Ne plus recommander sur le profil",
|
||||
"account.unfollow": "Ne plus suivre",
|
||||
|
@ -905,6 +906,10 @@
|
|||
"video.expand": "Agrandir la vidéo",
|
||||
"video.fullscreen": "Plein écran",
|
||||
"video.hide": "Masquer la vidéo",
|
||||
"video.mute": "Couper le son",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Lecture"
|
||||
"video.play": "Lecture",
|
||||
"video.unmute": "Rétablir le son",
|
||||
"video.volume_down": "Baisser le volume",
|
||||
"video.volume_up": "Augmenter le volume"
|
||||
}
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Keppeling nei profyl kopiearje",
|
||||
"account.direct": "Privee fermelde @{name}",
|
||||
"account.disable_notifications": "Jou gjin melding mear wannear @{name} in berjocht pleatst",
|
||||
"account.domain_blocked": "Domein blokkearre",
|
||||
"account.edit_profile": "Profyl bewurkje",
|
||||
"account.enable_notifications": "Jou in melding mear wannear @{name} in berjocht pleatst",
|
||||
"account.endorse": "Op profyl werjaan",
|
||||
"account.featured_tags.last_status_at": "Lêste berjocht op {date}",
|
||||
"account.featured_tags.last_status_never": "Gjin berjochten",
|
||||
"account.featured_tags.title": "Utljochte hashtags fan {name}",
|
||||
"account.follow": "Folgje",
|
||||
"account.follow_back": "Weromfolgje",
|
||||
"account.followers": "Folgers",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Meldingen negearje",
|
||||
"account.mute_short": "Negearje",
|
||||
"account.muted": "Negearre",
|
||||
"account.mutual": "Jimme folgje inoar",
|
||||
"account.no_bio": "Gjin omskriuwing opjûn.",
|
||||
"account.open_original_page": "Orizjinele side iepenje",
|
||||
"account.posts": "Berjochten",
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Cóipeáil nasc chuig an bpróifíl",
|
||||
"account.direct": "Luaigh @{name} go príobháideach",
|
||||
"account.disable_notifications": "Éirigh as ag cuir mé in eol nuair bpostálann @{name}",
|
||||
"account.domain_blocked": "Ainm fearainn bactha",
|
||||
"account.edit_profile": "Cuir an phróifíl in eagar",
|
||||
"account.enable_notifications": "Cuir mé in eol nuair bpostálann @{name}",
|
||||
"account.endorse": "Cuir ar an phróifíl mar ghné",
|
||||
"account.featured_tags.last_status_at": "Postáil is déanaí ar {date}",
|
||||
"account.featured_tags.last_status_never": "Gan aon phoist",
|
||||
"account.featured_tags.title": "Haischlib faoi thrácht {name}",
|
||||
"account.follow": "Lean",
|
||||
"account.follow_back": "Leanúint ar ais",
|
||||
"account.followers": "Leantóirí",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Balbhaigh fógraí",
|
||||
"account.mute_short": "Balbhaigh",
|
||||
"account.muted": "Balbhaithe",
|
||||
"account.mutual": "Frithpháirteach",
|
||||
"account.no_bio": "Níor tugadh tuairisc.",
|
||||
"account.open_original_page": "Oscail an leathanach bunaidh",
|
||||
"account.posts": "Postálacha",
|
||||
|
@ -65,6 +62,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} poist}}",
|
||||
"account.unblock": "Bain bac de @{name}",
|
||||
"account.unblock_domain": "Bain bac den ainm fearainn {domain}",
|
||||
"account.unblock_domain_short": "Díbhlocáil",
|
||||
"account.unblock_short": "Díbhlocáil",
|
||||
"account.unendorse": "Ná chuir ar an phróifíl mar ghné",
|
||||
"account.unfollow": "Ná lean a thuilleadh",
|
||||
|
@ -905,6 +903,12 @@
|
|||
"video.expand": "Leath físeán",
|
||||
"video.fullscreen": "Lánscáileán",
|
||||
"video.hide": "Cuir físeán i bhfolach",
|
||||
"video.mute": "Balbhaigh",
|
||||
"video.pause": "Cuir ar sos",
|
||||
"video.play": "Cuir ar siúl"
|
||||
"video.play": "Cuir ar siúl",
|
||||
"video.skip_backward": "Scipeáil siar",
|
||||
"video.skip_forward": "Scipeáil ar aghaidh",
|
||||
"video.unmute": "Díbhalbhú",
|
||||
"video.volume_down": "Toirt síos",
|
||||
"video.volume_up": "Toirt suas"
|
||||
}
|
||||
|
|
|
@ -23,13 +23,11 @@
|
|||
"account.copy": "Dèan lethbhreac dhen cheangal dhan phròifil",
|
||||
"account.direct": "Thoir iomradh air @{name} gu prìobhaideach",
|
||||
"account.disable_notifications": "Na cuir brath thugam tuilleadh nuair a chuireas @{name} post ris",
|
||||
"account.domain_blocked": "Chaidh an àrainn a bhacadh",
|
||||
"account.edit_profile": "Deasaich a’ phròifil",
|
||||
"account.enable_notifications": "Cuir brath thugam nuair a chuireas @{name} post ris",
|
||||
"account.endorse": "Brosnaich air a’ phròifil",
|
||||
"account.featured_tags.last_status_at": "Am post mu dheireadh {date}",
|
||||
"account.featured_tags.last_status_never": "Gun phost",
|
||||
"account.featured_tags.title": "Na tagaichean hais brosnaichte aig {name}",
|
||||
"account.follow": "Lean",
|
||||
"account.follow_back": "Lean air ais",
|
||||
"account.followers": "Luchd-leantainn",
|
||||
|
@ -52,7 +50,6 @@
|
|||
"account.mute_notifications_short": "Mùch na brathan",
|
||||
"account.mute_short": "Mùch",
|
||||
"account.muted": "’Ga mhùchadh",
|
||||
"account.mutual": "Co-dhàimh",
|
||||
"account.no_bio": "Cha deach tuairisgeul a sholar.",
|
||||
"account.open_original_page": "Fosgail an duilleag thùsail",
|
||||
"account.posts": "Postaichean",
|
||||
|
|
|
@ -23,13 +23,14 @@
|
|||
"account.copy": "Copiar ligazón ao perfil",
|
||||
"account.direct": "Mencionar de xeito privado a @{name}",
|
||||
"account.disable_notifications": "Deixar de notificarme cando @{name} publica",
|
||||
"account.domain_blocked": "Dominio agochado",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Noficarme cando @{name} publique",
|
||||
"account.endorse": "Amosar no perfil",
|
||||
"account.featured": "Destacado",
|
||||
"account.featured.hashtags": "Cancelos",
|
||||
"account.featured.posts": "Publicacións",
|
||||
"account.featured_tags.last_status_at": "Última publicación o {date}",
|
||||
"account.featured_tags.last_status_never": "Sen publicacións",
|
||||
"account.featured_tags.title": "Cancelos destacados de {name}",
|
||||
"account.follow": "Seguir",
|
||||
"account.follow_back": "Seguir tamén",
|
||||
"account.followers": "Seguidoras",
|
||||
|
@ -52,7 +53,6 @@
|
|||
"account.mute_notifications_short": "Silenciar notificacións",
|
||||
"account.mute_short": "Acalar",
|
||||
"account.muted": "Acalada",
|
||||
"account.mutual": "Mutuo",
|
||||
"account.no_bio": "Sen descrición.",
|
||||
"account.open_original_page": "Abrir páxina orixinal",
|
||||
"account.posts": "Publicacións",
|
||||
|
@ -378,6 +378,8 @@
|
|||
"generic.saved": "Gardado",
|
||||
"getting_started.heading": "Primeiros pasos",
|
||||
"hashtag.admin_moderation": "Abrir interface de moderación para ##{name}",
|
||||
"hashtag.browse": "Ver publicacións con #{hashtag}",
|
||||
"hashtag.browse_from_account": "Ver as publicacións de @{name} con #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "e {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "ou {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "sen {additional}",
|
||||
|
@ -391,6 +393,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicacións}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicacións}} hoxe",
|
||||
"hashtag.follow": "Seguir cancelo",
|
||||
"hashtag.mute": "Acalar a #{hashtag}",
|
||||
"hashtag.unfollow": "Deixar de seguir cancelo",
|
||||
"hashtags.and_other": "…e {count, plural, one {}other {# máis}}",
|
||||
"hints.profiles.followers_may_be_missing": "Poderían faltar seguidoras deste perfil.",
|
||||
|
|
|
@ -19,17 +19,20 @@
|
|||
"account.block_domain": "חסמו את קהילת {domain}",
|
||||
"account.block_short": "לחסום",
|
||||
"account.blocked": "לחסום",
|
||||
"account.blocking": "רשימת החשבונות החסומים",
|
||||
"account.cancel_follow_request": "משיכת בקשת מעקב",
|
||||
"account.copy": "להעתיק קישור לפרופיל",
|
||||
"account.direct": "הודעה פרטית אל @{name}",
|
||||
"account.disable_notifications": "הפסק לשלוח לי התראות כש@{name} מפרסמים",
|
||||
"account.domain_blocked": "הדומיין חסום",
|
||||
"account.domain_blocking": "רשימת השרתים החסומים",
|
||||
"account.edit_profile": "עריכת פרופיל",
|
||||
"account.enable_notifications": "שלח לי התראות כש@{name} מפרסם",
|
||||
"account.endorse": "קדם את החשבון בפרופיל",
|
||||
"account.featured": "מומלץ",
|
||||
"account.featured.hashtags": "תגיות",
|
||||
"account.featured.posts": "הודעות",
|
||||
"account.featured_tags.last_status_at": "חצרוץ אחרון בתאריך {date}",
|
||||
"account.featured_tags.last_status_never": "אין חצרוצים",
|
||||
"account.featured_tags.title": "התגיות המועדפות של {name}",
|
||||
"account.follow": "לעקוב",
|
||||
"account.follow_back": "לעקוב בחזרה",
|
||||
"account.followers": "עוקבים",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"account.following": "נעקבים",
|
||||
"account.following_counter": "{count, plural,one {עוקב אחרי {count}}other {עוקב אחרי {counter}}}",
|
||||
"account.follows.empty": "משתמש זה עדיין לא עוקב אחרי אף אחד.",
|
||||
"account.follows_you": "במעקב אחריך",
|
||||
"account.go_to_profile": "מעבר לפרופיל",
|
||||
"account.hide_reblogs": "להסתיר הידהודים מאת @{name}",
|
||||
"account.in_memoriam": "פרופיל זכרון.",
|
||||
|
@ -52,7 +56,8 @@
|
|||
"account.mute_notifications_short": "השתקת התראות",
|
||||
"account.mute_short": "השתקה",
|
||||
"account.muted": "מושתק",
|
||||
"account.mutual": "הדדיים",
|
||||
"account.muting": "רשימת החשבונות המושתקים",
|
||||
"account.mutual": "אתם עוקביםות הדדית",
|
||||
"account.no_bio": "לא סופק תיאור.",
|
||||
"account.open_original_page": "לפתיחת העמוד המקורי",
|
||||
"account.posts": "פוסטים",
|
||||
|
@ -60,6 +65,7 @@
|
|||
"account.report": "דווח על @{name}",
|
||||
"account.requested": "בהמתנה לאישור. לחצי כדי לבטל בקשת מעקב",
|
||||
"account.requested_follow": "{name} ביקשו לעקוב אחריך",
|
||||
"account.requests_to_follow_you": "ביקשו לעקוב אחריך",
|
||||
"account.share": "שתף את הפרופיל של @{name}",
|
||||
"account.show_reblogs": "הצג הדהודים מאת @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {הודעה אחת} two {הודעותיים} many {{counter} הודעות} other {{counter} הודעות}}",
|
||||
|
@ -294,6 +300,9 @@
|
|||
"emoji_button.search_results": "תוצאות חיפוש",
|
||||
"emoji_button.symbols": "סמלים",
|
||||
"emoji_button.travel": "טיולים ואתרים",
|
||||
"empty_column.account_featured.me": "עוד לא קידמת תכנים. הידעת שניתן לקדם תכני הודעות, תגיות שבשימושך התדיר או אפילו את החשבונות של חבריםות בפרופיל שלך?",
|
||||
"empty_column.account_featured.other": "{acct} עוד לא קידם תכנים. הידעת שניתן לקדם תכני הודעות, תגיות שבשימושך התדיר או אפילו את החשבונות של חבריםות בפרופיל שלך?",
|
||||
"empty_column.account_featured_other.unknown": "חשבון זה עוד לא קידם תכנים.",
|
||||
"empty_column.account_hides_collections": "המשתמש.ת בחר.ה להסתיר מידע זה",
|
||||
"empty_column.account_suspended": "חשבון מושעה",
|
||||
"empty_column.account_timeline": "אין עדיין אף הודעה!",
|
||||
|
@ -378,6 +387,8 @@
|
|||
"generic.saved": "נשמר",
|
||||
"getting_started.heading": "בואו נתחיל",
|
||||
"hashtag.admin_moderation": "פתיחת ממשק פיקוח דיון עבור #{name}",
|
||||
"hashtag.browse": "קריאת הודעות תחת #{hashtag}",
|
||||
"hashtag.browse_from_account": "קריאת הודעות מאת @{name} תחת #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "ו- {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "או {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "ללא {additional}",
|
||||
|
@ -391,6 +402,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural, one {הודעה אחת} two {הודעותיים} many {{counter} הודעות} other {{counter} הודעות}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {הודעה אחת} two {הודעותיים} many {{counter} הודעות} other {{counter} הודעות}} היום",
|
||||
"hashtag.follow": "לעקוב אחרי תגית",
|
||||
"hashtag.mute": "להשתיק את #{hashtag}",
|
||||
"hashtag.unfollow": "להפסיק לעקוב אחרי תגית",
|
||||
"hashtags.and_other": "…{count, plural,other {ועוד #}}",
|
||||
"hints.profiles.followers_may_be_missing": "יתכן כי עוקבים של פרופיל זה חסרים.",
|
||||
|
|
|
@ -22,13 +22,11 @@
|
|||
"account.copy": "प्रोफाइल पर लिंक कॉपी करें",
|
||||
"account.direct": "निजि तरीके से उल्लेख करे @{name}",
|
||||
"account.disable_notifications": "@{name} पोस्ट के लिए मुझे सूचित मत करो",
|
||||
"account.domain_blocked": "छिपा हुआ डोमेन",
|
||||
"account.edit_profile": "प्रोफ़ाइल संपादित करें",
|
||||
"account.enable_notifications": "जब @{name} पोस्ट मौजूद हो सूचित करें",
|
||||
"account.endorse": "प्रोफ़ाइल पर दिखाए",
|
||||
"account.featured_tags.last_status_at": "{date} का अंतिम पोस्ट",
|
||||
"account.featured_tags.last_status_never": "कोई पोस्ट नहीं है",
|
||||
"account.featured_tags.title": "{name} के चुनिंदा हैशटैग",
|
||||
"account.follow": "फॉलो करें",
|
||||
"account.follow_back": "फॉलो करें",
|
||||
"account.followers": "फॉलोवर",
|
||||
|
@ -49,7 +47,6 @@
|
|||
"account.mute_notifications_short": "सूचनाओ को शांत करे",
|
||||
"account.mute_short": "शांत करे",
|
||||
"account.muted": "म्यूट है",
|
||||
"account.mutual": "आपसी",
|
||||
"account.no_bio": "कोई विवरण नहि दिया गया हे",
|
||||
"account.open_original_page": "ओरिजिनल पोस्ट खोलें",
|
||||
"account.posts": "टूट्स",
|
||||
|
|
|
@ -22,13 +22,11 @@
|
|||
"account.copy": "Kopiraj vezu u profil",
|
||||
"account.direct": "Privatno spomeni @{name}",
|
||||
"account.disable_notifications": "Nemoj me obavjestiti kada @{name} napravi objavu",
|
||||
"account.domain_blocked": "Domena je blokirana",
|
||||
"account.edit_profile": "Uredi profil",
|
||||
"account.enable_notifications": "Obavjesti me kada @{name} napravi objavu",
|
||||
"account.endorse": "Istakni na profilu",
|
||||
"account.featured_tags.last_status_at": "Zadnji post {date}",
|
||||
"account.featured_tags.last_status_never": "Nema postova",
|
||||
"account.featured_tags.title": "Istaknuti hashtagovi {name}",
|
||||
"account.follow": "Prati",
|
||||
"account.follow_back": "Slijedi natrag",
|
||||
"account.followers": "Pratitelji",
|
||||
|
@ -48,7 +46,6 @@
|
|||
"account.mute_notifications_short": "Utišaj obavijesti",
|
||||
"account.mute_short": "Utišaj",
|
||||
"account.muted": "Utišano",
|
||||
"account.mutual": "Uzajamno",
|
||||
"account.no_bio": "Nije dan opis.",
|
||||
"account.open_original_page": "Otvori originalnu stranicu",
|
||||
"account.posts": "Objave",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue