Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote
This commit is contained in:
commit
f3c3ea42c2
301 changed files with 6618 additions and 3070 deletions
8
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
8
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
|
@ -50,7 +50,7 @@ body:
|
|||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
|
||||
placeholder: v4.3.0
|
||||
placeholder: v4.4.0-beta.1
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
@ -60,9 +60,9 @@ body:
|
|||
Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc.
|
||||
value: |
|
||||
Please at least include those informations:
|
||||
- Operating system: (eg. Ubuntu 22.04)
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.1)
|
||||
- Node.js version: (from `node --version`, eg. v20.18.0)
|
||||
- Operating system: (eg. Ubuntu 24.04.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.4)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
|
41
.github/workflows/chromatic.yml
vendored
Normal file
41
.github/workflows/chromatic.yml
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
name: 'Chromatic'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- renovate/*
|
||||
- stable-*
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/chromatic.yml'
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: Build Storybook
|
||||
run: yarn build-storybook
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@v12
|
||||
with:
|
||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
zip: true
|
||||
storybookBuildDir: 'storybook-static'
|
17
.github/workflows/test-ruby.yml
vendored
17
.github/workflows/test-ruby.yml
vendored
|
@ -337,6 +337,21 @@ jobs:
|
|||
- name: Load database schema
|
||||
run: './bin/rails db:create db:schema:load db:seed'
|
||||
|
||||
- name: Cache Playwright Chromium browser
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install Playwright Chromium browser (with deps)
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn run playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright Chromium browser deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: yarn run playwright install-deps chromium
|
||||
|
||||
- run: bin/rspec spec/system --tag streaming --tag js
|
||||
|
||||
- name: Archive logs
|
||||
|
@ -350,7 +365,7 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
name: e2e-screenshots-${{ matrix.ruby-version }}
|
||||
path: tmp/capybara/
|
||||
|
||||
test-search:
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -78,3 +78,6 @@ docker-compose.override.yml
|
|||
|
||||
# Ignore local-only rspec configuration
|
||||
.rspec-local
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
---
|
||||
Naming/BlockForwarding:
|
||||
EnforcedStyle: explicit
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
|
|
@ -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.7.
|
||||
# using RuboCop version 1.76.0.
|
||||
# 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
|
||||
|
@ -31,45 +31,14 @@ Metrics/PerceivedComplexity:
|
|||
- 'app/services/delivery_antenna_service.rb'
|
||||
- 'app/services/post_status_service.rb'
|
||||
|
||||
Rails/OutputSafety:
|
||||
Exclude:
|
||||
- 'config/initializers/simple_form.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars.
|
||||
Style/FetchEnvVar:
|
||||
Exclude:
|
||||
- 'config/environments/production.rb'
|
||||
- 'config/initializers/2_limited_federation_mode.rb'
|
||||
- 'config/initializers/3_omniauth.rb'
|
||||
- 'config/initializers/cache_buster.rb'
|
||||
- 'config/initializers/devise.rb'
|
||||
- 'config/initializers/paperclip.rb'
|
||||
- 'config/initializers/vapid.rb'
|
||||
- 'lib/tasks/repo.rake'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns.
|
||||
# SupportedStyles: annotated, template, unannotated
|
||||
# AllowedMethods: redirect
|
||||
Style/FormatStringToken:
|
||||
Exclude:
|
||||
- 'config/initializers/devise.rb'
|
||||
- 'lib/paperclip/color_extractor.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
||||
Style/GuardClause:
|
||||
Enabled: false
|
||||
|
||||
# Configuration parameters: AllowedMethods.
|
||||
# AllowedMethods: respond_to_missing?
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/lib/admin/system_check/message.rb'
|
||||
- 'app/lib/request.rb'
|
||||
- 'app/lib/webfinger.rb'
|
||||
- 'app/services/block_domain_service.rb'
|
||||
- 'app/services/fetch_resource_service.rb'
|
||||
- 'app/workers/domain_block_worker.rb'
|
||||
- 'app/workers/unfollow_follow_worker.rb'
|
||||
|
|
16
.storybook/main.ts
Normal file
16
.storybook/main.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-vitest',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
7
.storybook/manager.ts
Normal file
7
.storybook/manager.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { addons } from 'storybook/manager-api';
|
||||
|
||||
import theme from './storybook-theme';
|
||||
|
||||
addons.setConfig({
|
||||
theme,
|
||||
});
|
18
.storybook/preview-head.html
Normal file
18
.storybook/preview-head.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
<style>
|
||||
/* Increase docs font size */
|
||||
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)),
|
||||
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) {
|
||||
font-size: 1.0666rem; /* 17px */
|
||||
line-height: 1.585; /* 27px */
|
||||
}
|
||||
|
||||
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)) code,
|
||||
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) code {
|
||||
font-size: 0.875rem; /* ~15px */
|
||||
}
|
||||
|
||||
/* Bring numbers back for ordered lists */
|
||||
ol {
|
||||
list-style: revert !important;
|
||||
}
|
||||
</style>
|
29
.storybook/preview.ts
Normal file
29
.storybook/preview.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
// The addon package.json incorrectly exports types, so we need to override them here.
|
||||
// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
|
||||
declare module '@storybook/addon-vitest/vitest-plugin' {
|
||||
export * from '@storybook/addon-vitest/dist/vitest-plugin/index';
|
||||
}
|
||||
|
||||
export {};
|
7
.storybook/storybook-theme.ts
Normal file
7
.storybook/storybook-theme.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { create } from 'storybook/theming';
|
||||
|
||||
export default create({
|
||||
base: 'light',
|
||||
brandTitle: 'Mastodon Storybook',
|
||||
brandImage: 'https://joinmastodon.org/logos/wordmark-black-text.svg',
|
||||
});
|
8
.storybook/vitest.setup.ts
Normal file
8
.storybook/vitest.setup.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
256
CHANGELOG.md
256
CHANGELOG.md
|
@ -2,6 +2,262 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.0] - UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- **Add “Followers you know” widget to user profiles and hover cards** (#34652, #34678, #34681, #34697, #34699, #34769, #34774 and #34914 by @diondiondion)
|
||||
- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, and #34869 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Add endorsed accounts to featured tab in web UI (#34421 and #34568 by @Gargron)\
|
||||
This also includes the following new REST API endpoints:
|
||||
- `GET /api/v1/accounts/:id/endorsements`: https://docs.joinmastodon.org/methods/accounts/#endorsements
|
||||
- `POST /api/v1/accounts/:id/endorse`: https://docs.joinmastodon.org/methods/accounts/#endorse
|
||||
- `POST /api/v1/accounts/:id/unendorse`: https://docs.joinmastodon.org/methods/accounts/#unendorse
|
||||
- Add ability to add and remove hashtags from featured tags in web UI (#34489, #34887, and #34490 by @ClearlyClaire and @Gargron)\
|
||||
This is achieved through the new REST API endpoints:
|
||||
- `POST /api/v1/tags/:id/feature`: https://docs.joinmastodon.org/methods/tags/#feature
|
||||
- `POST /api/v1/tags/:id/unfeature`: https://docs.joinmastodon.org/methods/tags/#unfeature
|
||||
- Add reminder when about to post without alt text in web UI (#33760 and #33784 by @Gargron)
|
||||
- Add a warning in Web UI when composing a post when the selected and detected language are different (#33042, #33683, #33700, #33724, #33770, and #34193 by @ClearlyClaire and @Gargron)
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, and #34820 by @ChaosExAnima and @ClearlyClaire)\
|
||||
Rules are now shown in the user’s language, if a translation has been set.\
|
||||
In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations
|
||||
- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
|
||||
- Add experimental support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, and #34864 by @ClearlyClaire and @diondiondion)\
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented. Such quotes are currently only processed if the `inbound_quotes` experimental feature is enabled (`EXPERIMENTAL_FEATURES=inbound_quotes`).\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add option to remove account from followers in web UI (#34488 by @Gargron)
|
||||
- Add relationship tags to profiles and hover cards in web UI (#34467 and #34792 by @Gargron and @diondiondion)
|
||||
- Add ability to open posts in a new tab by middle-clicking in web UI (#32988, #33106, #33419, and #34700 by @ClearlyClaire, @Gargron, and @tribela)
|
||||
- Add new filter action to blur media (#34256 by @ClearlyClaire)\
|
||||
In the REST API, this adds a new possible value of `blur` to the `filter_action` attribute: https://docs.joinmastodon.org/entities/Filter/#filter_action
|
||||
- Add dropdown menu to hashtag links in web UI (#34393 by @Gargron)
|
||||
- **Add server setting to allow referrer** (#33214, #33239, #33903, and #34731 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @renchap)\
|
||||
In order to protect the privacy of users of small or thematic servers, Mastodon previously avoided transmitting referrer information when clicking outside links, which unfortunately made Mastodon completely invisible to other websites, even though the privacy implications on large generic servers are very limited.\
|
||||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, and #34527 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service, optionally using a provided template.
|
||||
- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\
|
||||
Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\
|
||||
The following REST API changes have been made to accommodate this:
|
||||
- `registrations.min_age` has been added to the `Instance` entity: https://docs.joinmastodon.org/entities/Instance/#registrations-min_age
|
||||
- the `date_of_birth` parameter has been added to the account creation API: https://docs.joinmastodon.org/methods/accounts/#create
|
||||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||
- **Add experimental FASP support** (#34031, #34415, and #34765 by @oneiros)\
|
||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||
- Add option to use system scrollbar styling (#32117 by @vmstan)
|
||||
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
||||
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
||||
- Add timestamp to all announcements in Web UI (#18329 by @ClearlyClaire)
|
||||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 by @oneiros)\
|
||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||
This experimental feature causes the server to recursively fetch replies in background tasks whenever a user opens a remote post. This happens asynchronously and the client is currently not notified of the existence of new replies, which will thus only be displayed the next time this post’s context gets requested.\
|
||||
This feature needs to be explicitly enabled server-side by setting `FETCH_REPLIES_ENABLED` environment variable to `true`.
|
||||
- Add simple feature flag system through the `EXPERIMENTAL_FEATURES` environment variable (#34038 and #34124 by @oneiros)\
|
||||
This allows enabling comma-separated feature flags for experimental features.\
|
||||
The current supported feature flags are `inbound_quotes`, `fasp` and `http_message_signatures`.
|
||||
- Add `dev:populate_sample_data` rake task to populate test data (#34676, #34733, #34771, #34787, and #34791 by @ClearlyClaire and @diondiondion)
|
||||
- Add support for displaying fallback representation when receiving MathML (#27107 by @4e554c4c)
|
||||
- Add warning for Elasticsearch index analyzers mismatch (#34515 and #34567 by @ClearlyClaire and @Gargron)
|
||||
- Add `-only-mapping` option to `tootctl search deploy` (#34466 and #34566 by @Gargron)
|
||||
- Add server-side support for grouping account sign-up notifications (#34298 by @ClearlyClaire)
|
||||
- Add `registrations.reason_required` attribute to `/api/v2/instance` response (#34280 by @ClearlyClaire)\
|
||||
This is documented at https://docs.joinmastodon.org/entities/Instance/#registrations-reason_required
|
||||
- Add `EXTRA_MEDIA_HOSTS` environment variable to add extra hosts to Content-Security-Policy (#34184 by @shleeable)
|
||||
- Add `Deprecation` headers on deprecated API endpoints (#34262 and #34397 by @ClearlyClaire)\
|
||||
This is documented at https://docs.joinmastodon.org/api/guidelines/#deprecations
|
||||
- Add `about`, `privacy_policy` and `terms_of_service` URLs to `/api/v2/instance` (#33849 by @ClearlyClaire)
|
||||
- Add API to delete media attachments that are not in use (#33991 and #34035 by @ClearlyClaire and @ThisIsMissEm)\
|
||||
`DELETE /api/v1/media/:id`: https://docs.joinmastodon.org/methods/media/#delete
|
||||
- Add optional `delete_media` parameter to `DELETE /api/v1/statuses/:id` (#33988 by @ClearlyClaire)\
|
||||
This is documented at https://docs.joinmastodon.org/methods/statuses/#delete
|
||||
- Add `og:locale` to expose status language in OpenGraph previews (#34012 by @ThisIsMissEm)
|
||||
- Add `-skip-filled-timeline` option to `tootctl feed build` to skip half-filled feeds (#33844 by @ClearlyClaire)
|
||||
- Add support for changing the base Docker registry with the `BASE_REGISTRY` `ARG` (#33712 by @wolfspyre)
|
||||
- Add an optional metric exporter (#33734, #33840, #34172, #34192, 34223)\
|
||||
Optionally enable the `prometheus_exporter` ruby gem (see https://github.com/discourse/prometheus_exporter) to collect and expose metrics. See the documentation for all the details: https://docs.joinmastodon.org/admin/config/#prometheus
|
||||
- Add `attribution_domains` attribute to `PATCH /api/v1/accounts/update_credentials` (#32730 by @c960657)\
|
||||
This is documented at https://docs.joinmastodon.org/methods/accounts/#update_credentials
|
||||
- Add support for standard WebPush in addition to previous draft (#33572, #33528, and #33587 by @ClearlyClaire and @p1gp1g)
|
||||
- Add support for Active Record query log tags (#33342 by @renchap)
|
||||
- Add OTel trace & span IDs to logs (#33339 and #33362 by @renchap)
|
||||
- Add missing `on_delete: :cascade` foreign keys option to various database columns (#33175 by @mjankowski)
|
||||
- Add explicit migration breakpoints (#33089 by @ClearlyClaire)
|
||||
- Add rel alternate rss/json links to pages for tags (#33179 by @mjankowski)
|
||||
- Add media attachment description limit to instance API response (#33153 by @mjankowski)\
|
||||
This adds the `configuration.media_attachments.description_limit` attribute to the `Instance` entity, documented at https://docs.joinmastodon.org/entities/Instance/#description_limit
|
||||
- Add `maxlength` to registration reason input (#33162 by @mjankowski)
|
||||
- Add `REPLICA_PREPARED_STATEMENTS` and `REPLICA_DB_TASKS` environment variables (#32908 by @shleeable)\
|
||||
See documentation at https://docs.joinmastodon.org/admin/scaling/#read-replicas
|
||||
- Add a range of reserved usernames to reduce potential misuse by malicious actors (#32828 by @jmking-iftas)
|
||||
- Add operations on relays to the admin audit log (#32819 by @ThisIsMissEm)
|
||||
- Add userinfo OAuth endpoint (#32548 by @ThisIsMissEm)
|
||||
- Add the standard VCS attributes to OpenTelemetry spans (#32904 by @renchap)
|
||||
- Add endpoint to remove web push subscription (#32626 by @oneiros)\
|
||||
Mastodon now sets a new `Unsubscribe-URL` request header when performing WebPush requests. This URL can be used by the WebPush server to disable the WebPush subscription on Mastodon’s side in case of unfixable errors.
|
||||
- Add missing content warning text to RSS feeds (#32406 by @mjankowski)
|
||||
- Add Swiss German to languages dropdown (#29281 by @FlohEinstein)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
|
||||
- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron)
|
||||
- Change design of audio player in web UI (#34520, #34740, and #34865 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of interaction modal in web UI (#33278 by @Gargron)
|
||||
- Change list timelines to reflect added and removed users retroactively (#32930 by @Gargron)
|
||||
- Change account search to be more forgiving of spaces (#34455 by @Gargron)
|
||||
- Change unfollow button label from “Mutual” to “Unfollow” in web UI (#34392 by @Gargron)
|
||||
- Change “Specific people” to “Private mention” in menu in web UI (#33963 by @Gargron)
|
||||
- Change language names in compose box language picker to be localized (#33402 by @c960657)
|
||||
- Change onboarding flow in web UI (#32998, #33119, and #33471 by @ClearlyClaire and @Gargron)
|
||||
- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan)
|
||||
- Change design of rich text elements in web UI (#32633 by @Gargron)
|
||||
- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm)
|
||||
- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat)
|
||||
- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire)
|
||||
- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion)
|
||||
- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron)
|
||||
- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron)
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, and #34732 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)\
|
||||
One known limitation is that themes’ main style file needs to have a very specific file name: `app/javascript/styles/:name.scss` where `:name` is the name of the theme in `config/themes.yml`
|
||||
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
|
||||
- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire)
|
||||
- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\
|
||||
If you want to preserve the old behavior, you can add `FORCE_DEFAULT_LOCALE=true`.
|
||||
- Change size of profile picture on profile page from 90px to 92px (#34807 by @larouxn)
|
||||
- Change passthrough video processing to emit `moov` atom at start of video (#34726 by @ClearlyClaire)
|
||||
- Change kerning to be disabled for Japanese text to preserve monospaced alignment for readability (#34448 by @nagutabby)
|
||||
- Change error handling of various endpoints to return 422 instead of 500 on invalid parameters (#29308, #34434, and #34452 by @danielmbrasil and @mjankowski)
|
||||
- Change Web UI to use `<time>` tags for various timestamps (#34131 by @scarf005)
|
||||
- Change devcontainer to be accessible from local network (#34269 by @ChaosExAnima)
|
||||
- Change video transcoding code to skip re-encoding yuvj420p videos (#34098 by @rinsuki)
|
||||
- Change web client settings to be saved earlier and more often (#34074 by @ClearlyClaire)
|
||||
- Change test coverage report generation to be disabled by default, with opt-in through the `COVERAGE` environment variable (#33824 by @mjankowski)
|
||||
- Change devcontainer to store bootsnap cache outside of bind mounts (#33677 by @c960657)
|
||||
- Change error handling in the `mastodon:setup` rake task to summarize encountered errors at the end (#33603 by @mjankowski)
|
||||
- Change tooltip of some moderation interface timestamps to include time in addition to date (#33191 by @ThisIsMissEm)
|
||||
- Change organization and wording of `README.md`, `CONTRIBUTING.md` and `DEVELOPMENT.md` (#32143, #33328, #33517, #33637, #33728, #34675, and #34761 by @Lamparter, @andypiper, @diondiondion, @larouxn, @mikkelricky, and @mjankowski)
|
||||
- Change custom CSS to be cached for longer and invalidated based on its contents (#33207 and #33583 by @mjankowski and @tribela)
|
||||
- Change `tootctl maintenance fix-duplicates` to disable database statement timeouts (#33484 by @mjankowski)
|
||||
- Change some icons in settings sidebar to avoid “double icon” near each other (#33449 by @mjankowski)
|
||||
- Change animation on feed generation screen in web UI (#33311 by @Gargron)
|
||||
- Change OTel instrumentation to not start traces with Redis spans (#33090 by @robbkidd)
|
||||
- Change new post delivery to skip suspended followers (#27509 and #33030 by @ClearlyClaire and @oneiros)
|
||||
- Change URL truncation to account for ellipses (#33229 by @FND)
|
||||
- Change ability to navigate of unconfirmed users (#33209 by @Gargron)
|
||||
- Change hashtag trends to be stored in the database instead of redis (#32837, #33189, and #34016 by @Gargron and @onekopaka)
|
||||
- Change “social web” to “fediverse” in a few banners in web UI (#33101 by @Gargron)
|
||||
- Change server rules to be collapsible (#33039 by @Gargron)
|
||||
- Change design of modal loading and error screens in web UI (#33092 by @Gargron)
|
||||
- Change error messages to be more accurate when failing to add an account to a list (#33082 by @Gargron)
|
||||
- Change timezone picker in the default settings to show the default timezone (#31803 by @c960657)
|
||||
- Change `tootctl accounts modify --disable-2fa` to remove webauthn credentials (#29883 by @mszpro)
|
||||
- Change preview card processing to be more liberal in what it accepts (#31357 by @c960657)
|
||||
- Change scheduled statuses to be discarded if the author’s account is frozen (#30729 by @PauloVilarinho)
|
||||
- Change display of statuses in admin panel (#30813 by @ThisIsMissEm)
|
||||
- Change parsing of `ALLOWED_PRIVATE_ADDRESSES` to happen at startup (#32850 by @ClearlyClaire)
|
||||
- Change WebPush delivery to skip notifications older than 2 days old (#32842 by @ThisIsMissEm)
|
||||
- Change PWA manifest to prefer official mobile apps (#27254 by @jake-anto)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Remove support for Redis namespaces** (#34664 and #34665 by @ClearlyClaire)\
|
||||
See https://github.com/mastodon/redis_namespace_migration
|
||||
- Remove support for imports started on pre-4.2.0 Mastodon versions (#34371 by @mjankowski)
|
||||
- Remove support for PostgreSQL 12 and earlier (#34744 by @ClearlyClaire)
|
||||
- Remove support for Node.JS < 20 (#34390 by @renchap)
|
||||
- Remove support for Redis < 6.2 (#30413 by @ClearlyClaire)
|
||||
- Remove support for Ruby 3.1 (#32363 by @mjankowski)
|
||||
- Remove support for OAuth Password Grant Type (#30960 by @ThisIsMissEm)\
|
||||
https://docs.joinmastodon.org/spec/oauth/#token
|
||||
- Remove `OTP_SECRET` environment variable and legacy OTP code (#34743, #34757, #34748, and #34810 by @ClearlyClaire and @mjankowski)\
|
||||
This breaks zero-downtime migrations from versions earlier than 4.3.0.
|
||||
- Remove broken support for HTTP Basic Authentication (#34501 by @ThisIsMissEm)
|
||||
- Remove system tooltip for alt text in web UI (#33736 by @Gargron)
|
||||
- Remove `thing_type` and `thing_id` columns from settings table (#31971 and #33196 by @ClearlyClaire and @mjankowski)
|
||||
- Remove redundant temporary index creation in `tootctl status remove` (#33023 by @ClearlyClaire)
|
||||
- Remove duplicate indexes from database (#32454 by @mjankowski)
|
||||
- Remove redundant title attribute in column links (#32258 by @c960657)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix remote suspension of a user causing local instance to remove remote follows (#27588 by @ShadowJonathan)
|
||||
- Fix blocked accounts not being automatically removed from trending statuses (#34891 by @ClearlyClaire)
|
||||
- Fix nested buttons in search popout in web UI (#34871 by @Gargron)
|
||||
- Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron)
|
||||
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
|
||||
- Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire)
|
||||
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
|
||||
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
|
||||
- Fix long link names in admin sidebar being truncated (#34727 by @diondiondion)
|
||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||
- Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy)
|
||||
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
|
||||
- Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap)
|
||||
- Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
|
||||
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
||||
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
||||
- Fix being unable to hide controls in full screen video in web UI (#34308 by @Gargron)
|
||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||
- Fix SASS deprecation notices (#34278 by @ChaosExAnima)
|
||||
- Fix display of failed-to-load image attachments in web UI (#34217 by @Gargron)
|
||||
- Fix duplicate REST API requests on submitting account personal note with ctrl+enter (#34213 by @ClearlyClaire)
|
||||
- Fix unnecessary rerenders in composer dropdown menu (#34133 by @ClearlyClaire)
|
||||
- Fix behavior of database schema loading with `SKIP_POST_DEPLOYMENT_MIGRATIONS` (#34089 by @ClearlyClaire)
|
||||
- Fix infinite scroll not working on profile media tab in web UI (#33860 and #34171 by @ClearlyClaire and @Gargron)
|
||||
- Fix minor inefficiencies in domain suspension code (#33897 by @larouxn)
|
||||
- Fix potential inefficiency in media privacy system check (#33858 by @ClearlyClaire)
|
||||
- Fix public timeline inefficiency by adding the `language` column to the public timelines index (#33779 by @ClearlyClaire)
|
||||
- Fix re-encoding of high-framerate VFR videos with FFmpeg 6+ (#33634 by @ClearlyClaire)
|
||||
- Fix error when processing invalid `Announce` activity with missing object (#33570 by @ShadowJonathan)
|
||||
- Fix color contrast in report modal (#33468 by @ClearlyClaire)
|
||||
- Fix error 500 when passing an invalid `lang` parameter (#33467 by @ClearlyClaire)
|
||||
- Fix `/share` not using server-set characters limit (#33459 by @kescherCode)
|
||||
- Fix audio player modal having white-on-white buttons in light theme (#33444 by @ClearlyClaire)
|
||||
- Fix favorite & bookmark text toggle in timeline, status and image view (#27209 by @gunchleoc)
|
||||
- Fix Web UI erroneously stopping to offer expanding search results after second page (#33428 by @ClearlyClaire)
|
||||
- Fix missing value limits for `UserRole` position (#33172 and #33349 by @mjankowski)
|
||||
- Fix clicking on a profile mention while logged out potentially leading to incorrect account (#33324 by @ClearlyClaire)
|
||||
- Fix missing `NOT NULL` constraints on various database columns (#33244, #33284, #33308, #33330, #33374, and #34498 by @ClearlyClaire and @mjankowski)
|
||||
- Fix long account username overflowing on profiles (#33286 by @mjankowski)
|
||||
- Fix Vagrant failure to sync dangling symlinks (#28101 by @filippog)
|
||||
- Fix Chromium showing scrollbar on embedded posts (#33237 by @ClearlyClaire)
|
||||
- Fix missing top border on Admin Hashtags UI (#31443 by @ThisIsMissEm)
|
||||
- Fix design of search bar on explore screen in light theme in web UI (#33224 by @Gargron)
|
||||
- Fix various visual sign-up flow issues (#33206 by @Gargron)
|
||||
- Fix support of bidi text in account profiles (#33088 by @mokazemi)
|
||||
- Fix wording of the error returned when scheduling a status too soon (#33156 by @mjankowski)
|
||||
- Fix `inbox_url` presence on Relay not being validated (#32364 by @mjankowski)
|
||||
- Fix ability to include multiple copies of `embed.js` (#33107 by @YKWeyer)
|
||||
- Fix `rel="me"` check being case-sensitive (#32238 by @c960657)
|
||||
- Fix wrong video dimensions for some rotated videos (#33008 and #33261 by @Gargron and @tribela)
|
||||
- Fix error when viewing statuses to deleted replies in moderation view (#32986 by @ClearlyClaire)
|
||||
- Fix missing autofocus on boost modal (#32953 by @tribela)
|
||||
- Fix logic in “last used at per application” OAuth token list (#32912 by @mjankowski)
|
||||
- Fix admin dashboard linking to pages the user does not have permission to see (#32843 by @ThisIsMissEm)
|
||||
- Fix backspace navigation hotkey going back two pages instead of one on some browsers (#32826 by @c960657)
|
||||
- Fix typo in translation string (#32821 by @ThisIsMissEm)
|
||||
- Fix list of follow requests not having a back button (#32797 by @ClearlyClaire)
|
||||
- Fix out-of-view post contents being inconsistent with in-view post contents (#32778, #32887, and #32895 by @ClearlyClaire)
|
||||
- Fix `httplog` gem being used in production (#32776 and #32796 by @ClearlyClaire and @oneiros)
|
||||
- Fix use of deprecated `execCommand` for copying text by using the `clipboard` API (#32598 by @renchap)
|
||||
- Fix some translation strings not being properly pluralized (#27094 by @gunchleoc)
|
||||
|
||||
## [4.3.8] - 2025-05-06
|
||||
|
||||
### Security
|
||||
|
|
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
|||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.16.1
|
||||
ARG VIPS_VERSION=8.17.0
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
|
10
Gemfile
10
Gemfile
|
@ -53,7 +53,7 @@ gem 'fastimage'
|
|||
gem 'hiredis', '~> 0.6'
|
||||
gem 'hiredis-client'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.2.0'
|
||||
gem 'http', '~> 5.3.0'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.7.0', require: false
|
||||
gem 'i18n'
|
||||
|
@ -74,7 +74,7 @@ gem 'premailer-rails'
|
|||
gem 'public_suffix', '~> 6.0'
|
||||
gem 'pundit', '~> 2.3'
|
||||
gem 'rack-attack', '~> 6.6'
|
||||
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails-i18n', '~> 8.0'
|
||||
gem 'redcarpet', '~> 3.6'
|
||||
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
||||
|
@ -110,7 +110,7 @@ group :opentelemetry do
|
|||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||
|
@ -137,7 +137,7 @@ group :test do
|
|||
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
gem 'selenium-webdriver'
|
||||
gem 'capybara-playwright-driver'
|
||||
|
||||
# Used to reset the database between system tests
|
||||
gem 'database_cleaner-active_record'
|
||||
|
@ -201,7 +201,7 @@ group :development, :test do
|
|||
gem 'faker', '~> 3.2'
|
||||
|
||||
# Generate factory objects
|
||||
gem 'fabrication', '~> 2.30'
|
||||
gem 'fabrication'
|
||||
|
||||
# Profiling tools
|
||||
gem 'memory_profiler', require: false
|
||||
|
|
58
Gemfile.lock
58
Gemfile.lock
|
@ -90,7 +90,7 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.14.0)
|
||||
annotaterb (4.15.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
|
@ -111,7 +111,7 @@ GEM
|
|||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.8)
|
||||
rexml
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
|
@ -142,6 +142,10 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
capybara-playwright-driver (0.5.6)
|
||||
addressable
|
||||
capybara
|
||||
playwright-ruby-client (>= 1.16.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
cbor (0.5.9.8)
|
||||
|
@ -168,7 +172,7 @@ GEM
|
|||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.4)
|
||||
csv (3.3.5)
|
||||
database_cleaner-active_record (2.2.1)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
|
@ -223,7 +227,7 @@ GEM
|
|||
tzinfo
|
||||
excon (1.2.5)
|
||||
logger
|
||||
fabrication (2.31.0)
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
|
@ -297,9 +301,8 @@ GEM
|
|||
redis-client (= 0.24.0)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.2.0)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
base64 (~> 0.1)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
|
@ -397,7 +400,7 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.2)
|
||||
linzer (0.7.3)
|
||||
cgi (~> 0.4.2)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
|
@ -455,7 +458,7 @@ GEM
|
|||
nokogiri (1.18.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.10)
|
||||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
|
@ -544,7 +547,7 @@ GEM
|
|||
opentelemetry-instrumentation-excon (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (0.26.0)
|
||||
opentelemetry-instrumentation-faraday (0.27.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.24.0)
|
||||
|
@ -604,6 +607,9 @@ GEM
|
|||
pg (1.5.9)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.52.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
|
@ -633,11 +639,12 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.15)
|
||||
rack (3.1.16)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-cors (3.0.0)
|
||||
logger
|
||||
rack (>= 3.0.14)
|
||||
rack-oauth2 (2.2.1)
|
||||
activesupport
|
||||
attr_required
|
||||
|
@ -691,7 +698,7 @@ GEM
|
|||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.3.0)
|
||||
rdf (3.3.2)
|
||||
bcp47_spec (~> 0.2)
|
||||
bigdecimal (~> 3.1, >= 3.1.5)
|
||||
|
@ -750,7 +757,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.3)
|
||||
rubocop (1.75.8)
|
||||
rubocop (1.76.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -758,10 +765,10 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-ast (>= 1.45.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.45.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
|
@ -793,7 +800,7 @@ GEM
|
|||
ruby-saml (1.18.0)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.2.3)
|
||||
ruby-vips (2.2.4)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (2.4.1)
|
||||
|
@ -808,12 +815,6 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.33.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.9)
|
||||
|
@ -927,7 +928,6 @@ GEM
|
|||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
|
@ -955,6 +955,7 @@ DEPENDENCIES
|
|||
browser
|
||||
bundler-audit (~> 0.9)
|
||||
capybara (~> 3.39)
|
||||
capybara-playwright-driver
|
||||
charlock_holmes (~> 0.7.7)
|
||||
chewy (~> 7.3)
|
||||
climate_control
|
||||
|
@ -972,7 +973,7 @@ DEPENDENCIES
|
|||
doorkeeper (~> 5.6)
|
||||
dotenv
|
||||
email_spec
|
||||
fabrication (~> 2.30)
|
||||
fabrication
|
||||
faker (~> 3.2)
|
||||
faraday-httpclient
|
||||
fast_blank (~> 1.0)
|
||||
|
@ -986,7 +987,7 @@ DEPENDENCIES
|
|||
hiredis (~> 0.6)
|
||||
hiredis-client
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.2.0)
|
||||
http (~> 5.3.0)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.7.0)
|
||||
i18n
|
||||
|
@ -1025,7 +1026,7 @@ DEPENDENCIES
|
|||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.26.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http (~> 0.24.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||
|
@ -1046,7 +1047,7 @@ DEPENDENCIES
|
|||
puma (~> 6.3)
|
||||
pundit (~> 2.3)
|
||||
rack-attack (~> 6.6)
|
||||
rack-cors (~> 2.0)
|
||||
rack-cors
|
||||
rack-test (~> 2.1)
|
||||
rails (~> 8.0)
|
||||
rails-i18n (~> 8.0)
|
||||
|
@ -1070,7 +1071,6 @@ DEPENDENCIES
|
|||
rubyzip (~> 2.3)
|
||||
sanitize (~> 7.0)
|
||||
scenic (~> 1.7)
|
||||
selenium-webdriver
|
||||
shoulda-matchers
|
||||
sidekiq (< 8)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
|
|
|
@ -138,7 +138,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
set_locale { render :rules }
|
||||
end
|
||||
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicateName
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
|
||||
if params[:action] == 'create'
|
||||
false # Disable flash messages for sign-up
|
||||
else
|
||||
|
|
|
@ -22,6 +22,18 @@ module SignatureVerification
|
|||
request.headers['Signature'].present?
|
||||
end
|
||||
|
||||
def signature_key_id
|
||||
signed_request.key_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signed_request
|
||||
@signed_request ||= SignedRequest.new(request) if signed_request?
|
||||
rescue SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
def signature_verification_failure_reason
|
||||
@signature_verification_failure_reason
|
||||
end
|
||||
|
@ -30,12 +42,6 @@ module SignatureVerification
|
|||
@signature_verification_failure_code || 401
|
||||
end
|
||||
|
||||
def signature_key_id
|
||||
signature_params['keyId']
|
||||
rescue Mastodon::SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
def signed_request_account
|
||||
signed_request_actor.is_a?(Account) ? signed_request_actor : nil
|
||||
end
|
||||
|
@ -44,38 +50,20 @@ module SignatureVerification
|
|||
return @signed_request_actor if defined?(@signed_request_actor)
|
||||
|
||||
raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||
raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
||||
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||
|
||||
verify_signature_strength!
|
||||
verify_body_digest!
|
||||
actor = actor_from_key_id
|
||||
|
||||
actor = actor_from_key_id(signature_params['keyId'])
|
||||
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if actor.nil?
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||
|
||||
signature = Base64.decode64(signature_params['signature'])
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if actor.nil?
|
||||
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
|
@ -86,12 +74,6 @@ module SignatureVerification
|
|||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
def request_body
|
||||
@request_body ||= request.raw_post
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fail_with!(message, **options)
|
||||
Rails.logger.debug { "Signature verification failed: #{message}" }
|
||||
|
||||
|
@ -99,123 +81,8 @@ module SignatureVerification
|
|||
@signed_request_actor = nil
|
||||
end
|
||||
|
||||
def signature_params
|
||||
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
||||
rescue SignatureParser::ParsingError
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
|
||||
end
|
||||
|
||||
def signature_algorithm
|
||||
signature_params.fetch('algorithm', 'hs2019')
|
||||
end
|
||||
|
||||
def signed_headers
|
||||
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split
|
||||
end
|
||||
|
||||
def verify_signature_strength!
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
||||
end
|
||||
|
||||
def verify_body_digest!
|
||||
return unless signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
|
||||
|
||||
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
|
||||
sha256 = digests.assoc('sha-256')
|
||||
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
|
||||
|
||||
return if body_digest == sha256[1]
|
||||
|
||||
digest_size = begin
|
||||
Base64.strict_decode64(sha256[1].strip).length
|
||||
rescue ArgumentError
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
|
||||
end
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
|
||||
end
|
||||
|
||||
def verify_signature(actor, signature, compare_signed_string)
|
||||
if actor.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string)
|
||||
@signed_request_actor = actor
|
||||
@signed_request_actor
|
||||
end
|
||||
rescue OpenSSL::PKey::RSAError
|
||||
nil
|
||||
end
|
||||
|
||||
def build_signed_string(include_query_string: true)
|
||||
signed_headers.map do |signed_header|
|
||||
case signed_header
|
||||
when HttpSignatureDraft::REQUEST_TARGET
|
||||
if include_query_string
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
|
||||
else
|
||||
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||
# TODO: remove eventually some time after release of the fixed version
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
end
|
||||
when '(created)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||
|
||||
"(created): #{signature_params['created']}"
|
||||
when '(expires)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
"(expires): #{signature_params['expires']}"
|
||||
else
|
||||
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def matches_time_window?
|
||||
created_time = nil
|
||||
expires_time = nil
|
||||
|
||||
begin
|
||||
if signature_algorithm == 'hs2019' && signature_params['created'].present?
|
||||
created_time = Time.at(signature_params['created'].to_i).utc
|
||||
elsif request.headers['Date'].present?
|
||||
created_time = Time.httpdate(request.headers['Date']).utc
|
||||
end
|
||||
|
||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
|
||||
|
||||
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
|
||||
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def body_digest
|
||||
@body_digest ||= Digest::SHA256.base64digest(request_body)
|
||||
end
|
||||
|
||||
def to_header_name(name)
|
||||
name.split('-').map(&:capitalize).join('-')
|
||||
end
|
||||
|
||||
def missing_required_signature_parameters?
|
||||
signature_params['keyId'].blank? || signature_params['signature'].blank?
|
||||
end
|
||||
|
||||
def actor_from_key_id(key_id)
|
||||
def actor_from_key_id
|
||||
key_id = signature_key_id
|
||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||
|
||||
if domain_not_allowed?(domain)
|
||||
|
|
|
@ -31,14 +31,7 @@ module FormattingHelper
|
|||
end
|
||||
|
||||
def status_content_format(status)
|
||||
MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span|
|
||||
span.add_attributes(
|
||||
'app.formatter.content.type' => 'status',
|
||||
'app.formatter.content.origin' => status.local? ? 'local' : 'remote'
|
||||
)
|
||||
|
||||
html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
|
||||
end
|
||||
html_aware_format(status.text, status.local?, markdown: status.markdown, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
|
||||
end
|
||||
|
||||
def rss_status_content_format(status)
|
||||
|
@ -50,14 +43,7 @@ module FormattingHelper
|
|||
end
|
||||
|
||||
def account_bio_format(account)
|
||||
MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span|
|
||||
span.add_attributes(
|
||||
'app.formatter.content.type' => 'account_bio',
|
||||
'app.formatter.content.origin' => account.local? ? 'local' : 'remote'
|
||||
)
|
||||
|
||||
html_aware_format(account.note, account.local?, markdown: account.user&.setting_bio_markdown)
|
||||
end
|
||||
html_aware_format(account.note, account.local?, markdown: account.user&.setting_bio_markdown)
|
||||
end
|
||||
|
||||
def account_field_value_format(field, with_rel_me: true)
|
||||
|
|
|
@ -26,6 +26,8 @@ module JsonLdHelper
|
|||
# The url attribute can be a string, an array of strings, or an array of objects.
|
||||
# The objects could include a mimeType. Not-included mimeType means it's text/html.
|
||||
def url_to_href(value, preferred_type = nil)
|
||||
value = [value] if value.is_a?(Hash)
|
||||
|
||||
single_value = if value.is_a?(Array) && !value.first.is_a?(String)
|
||||
value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
|
||||
elsif value.is_a?(Array)
|
||||
|
@ -41,6 +43,15 @@ module JsonLdHelper
|
|||
end
|
||||
end
|
||||
|
||||
def url_to_media_type(value, preferred_type = nil)
|
||||
value = [value] if value.is_a?(Hash)
|
||||
return unless value.is_a?(Array) && !value.first.is_a?(String)
|
||||
|
||||
single_value = value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
|
||||
|
||||
single_value['mediaType'] unless single_value.nil?
|
||||
end
|
||||
|
||||
def as_array(value)
|
||||
if value.nil?
|
||||
[]
|
||||
|
|
|
@ -2,6 +2,7 @@ import { browserHistory } from 'mastodon/components/router';
|
|||
import { debounceWithDispatchAndArguments } from 'mastodon/utils/debounce';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import {
|
||||
followAccountSuccess, unfollowAccountSuccess,
|
||||
|
@ -12,6 +13,7 @@ import {
|
|||
blockAccountSuccess, unblockAccountSuccess,
|
||||
pinAccountSuccess, unpinAccountSuccess,
|
||||
fetchRelationshipsSuccess,
|
||||
fetchEndorsedAccounts,
|
||||
} from './accounts_typed';
|
||||
import { importFetchedAccount, importFetchedAccounts } from './importer';
|
||||
|
||||
|
@ -634,6 +636,7 @@ export function pinAccount(id) {
|
|||
|
||||
api().post(`/api/v1/accounts/${id}/pin`).then(response => {
|
||||
dispatch(pinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(pinAccountFail(error));
|
||||
});
|
||||
|
@ -646,6 +649,7 @@ export function unpinAccount(id) {
|
|||
|
||||
api().post(`/api/v1/accounts/${id}/unpin`).then(response => {
|
||||
dispatch(unpinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(unpinAccountFail(error));
|
||||
});
|
||||
|
|
7
app/javascript/mastodon/actions/navigation.ts
Normal file
7
app/javascript/mastodon/actions/navigation.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openNavigation = createAction('navigation/open');
|
||||
|
||||
export const closeNavigation = createAction('navigation/close');
|
||||
|
||||
export const toggleNavigation = createAction('navigation/toggle');
|
|
@ -121,10 +121,15 @@ export const clickSearchResult = createAppAsyncThunk(
|
|||
|
||||
export const forgetSearchResult = createAppAsyncThunk(
|
||||
'search/forgetResult',
|
||||
(q: string, { dispatch, getState }) => {
|
||||
(
|
||||
{ q, type }: { q: string; type?: RecentSearchType },
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const previous = getState().search.recent;
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = previous.filter((result) => result.q !== q);
|
||||
const current = previous.filter(
|
||||
(result) => result.q !== q || result.type !== type,
|
||||
);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
|
|
|
@ -14,6 +14,8 @@ import {
|
|||
muteAccount,
|
||||
unmuteAccount,
|
||||
followAccountSuccess,
|
||||
unpinAccount,
|
||||
pinAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { showAlertForError } from 'mastodon/actions/alerts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
@ -64,7 +66,7 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
export const Account: React.FC<{
|
||||
interface AccountProps {
|
||||
size?: number;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
|
@ -73,7 +75,10 @@ export const Account: React.FC<{
|
|||
withBio?: boolean;
|
||||
hideButtons?: boolean;
|
||||
children?: ReactNode;
|
||||
}> = ({
|
||||
withMenu?: boolean;
|
||||
}
|
||||
|
||||
export const Account: React.FC<AccountProps> = ({
|
||||
id,
|
||||
size = 46,
|
||||
hidden,
|
||||
|
@ -82,6 +87,7 @@ export const Account: React.FC<{
|
|||
withBio,
|
||||
hideButtons,
|
||||
children,
|
||||
withMenu = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn } = useIdentity();
|
||||
|
@ -132,8 +138,6 @@ export const Account: React.FC<{
|
|||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
arr = [];
|
||||
|
||||
if (isRemote && accountUrl) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
|
@ -186,6 +190,25 @@ export const Account: React.FC<{
|
|||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
});
|
||||
|
||||
if (id !== me && (relationship?.following || relationship?.requested)) {
|
||||
const handleEndorseToggle = () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(id));
|
||||
} else {
|
||||
dispatch(pinAccount(id));
|
||||
}
|
||||
};
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
// Defined in features/account_timeline/components/account_header.tsx
|
||||
relationship.endorsed
|
||||
? { id: 'account.unendorse' }
|
||||
: { id: 'account.endorse' },
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -210,9 +233,10 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let button: React.ReactNode, dropdown: React.ReactNode;
|
||||
let button: React.ReactNode;
|
||||
let dropdown: React.ReactNode;
|
||||
|
||||
if (menu.length > 0) {
|
||||
if (menu.length > 0 && withMenu) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
|
@ -268,43 +292,69 @@ export const Account: React.FC<{
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Link
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
<div
|
||||
className={classNames('account', {
|
||||
'account--minimal': minimal,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames('account__wrapper', {
|
||||
'account__wrapper--with-bio': account && withBio,
|
||||
})}
|
||||
>
|
||||
<div className='account__info-wrapper'>
|
||||
<Link
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
isHide={account.other_settings.hide_followers_count}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
isHide={account.other_settings.hide_followers_count}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!minimal && children && (
|
||||
<div>
|
||||
|
@ -322,22 +372,6 @@ export const Account: React.FC<{
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ interface Props {
|
|||
withLink?: boolean;
|
||||
counter?: number | string;
|
||||
counterBorderColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
|
@ -27,6 +28,7 @@ export const Avatar: React.FC<Props> = ({
|
|||
inline = false,
|
||||
withLink = false,
|
||||
style: styleFromParent,
|
||||
className,
|
||||
counter,
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
|
@ -52,7 +54,7 @@ export const Avatar: React.FC<Props> = ({
|
|||
|
||||
const avatar = (
|
||||
<div
|
||||
className={classNames('account__avatar', {
|
||||
className={classNames(className, 'account__avatar', {
|
||||
'account__avatar--inline': inline,
|
||||
'account__avatar--loading': loading,
|
||||
})}
|
||||
|
|
97
app/javascript/mastodon/components/button/button.stories.tsx
Normal file
97
app/javascript/mastodon/components/button/button.stories.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn, expect } from 'storybook/test';
|
||||
|
||||
import { Button } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
args: {
|
||||
secondary: false,
|
||||
compact: false,
|
||||
dangerous: false,
|
||||
disabled: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
text: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
description:
|
||||
'Alternative way of specifying the button label. Will override `children` if provided.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
control: 'text',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['test'],
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
|
||||
await userEvent.click(canvas.getByRole('button'));
|
||||
await expect(args.onClick).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
const disabledButtonTest: Story['play'] = async ({
|
||||
args,
|
||||
canvas,
|
||||
userEvent,
|
||||
}) => {
|
||||
await userEvent.click(canvas.getByRole('button'));
|
||||
await expect(args.onClick).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: 'Primary button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
secondary: true,
|
||||
children: 'Secondary button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
children: 'Compact button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Dangerous: Story = {
|
||||
args: {
|
||||
dangerous: true,
|
||||
children: 'Dangerous button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const PrimaryDisabled: Story = {
|
||||
args: {
|
||||
...Primary.args,
|
||||
disabled: true,
|
||||
},
|
||||
play: disabledButtonTest,
|
||||
};
|
||||
|
||||
export const SecondaryDisabled: Story = {
|
||||
args: {
|
||||
...Secondary.args,
|
||||
disabled: true,
|
||||
},
|
||||
play: disabledButtonTest,
|
||||
};
|
|
@ -22,6 +22,10 @@ interface PropsWithText extends BaseProps {
|
|||
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction that doesn't result in navigation.
|
||||
*/
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
type = 'button',
|
||||
onClick,
|
|
@ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react';
|
||||
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
|
@ -238,7 +239,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
onClick={handleToggleClick}
|
||||
>
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id='sliders' icon={SettingsIcon} />
|
||||
<Icon
|
||||
id='sliders'
|
||||
icon={collapsed ? UnfoldMoreIcon : UnfoldLessIcon}
|
||||
/>
|
||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
</button>
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import type { ComponentPropsWithRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useId,
|
||||
} from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -11,11 +18,14 @@ import { animated, useSpring } from '@react-spring/web';
|
|||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import StatusContainer from '@/mastodon/containers/status_container';
|
||||
import { usePrevious } from '@/mastodon/hooks/usePrevious';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||
|
||||
const messages = defineMessages({
|
||||
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
|
||||
|
@ -31,6 +41,7 @@ export const FeaturedCarousel: React.FC<{
|
|||
tagged?: string;
|
||||
}> = ({ accountId, tagged }) => {
|
||||
const intl = useIntl();
|
||||
const accessibilityId = useId();
|
||||
|
||||
// Load pinned statuses
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -74,6 +85,7 @@ export const FeaturedCarousel: React.FC<{
|
|||
const [currentSlideHeight, setCurrentSlideHeight] = useState(
|
||||
wrapperRef.current?.scrollHeight ?? 0,
|
||||
);
|
||||
const previousSlideHeight = usePrevious(currentSlideHeight);
|
||||
const observerRef = useRef<ResizeObserver>(
|
||||
new ResizeObserver(() => {
|
||||
handleSlideChange(0);
|
||||
|
@ -82,8 +94,10 @@ export const FeaturedCarousel: React.FC<{
|
|||
const wrapperStyles = useSpring({
|
||||
x: `-${slideIndex * 100}%`,
|
||||
height: currentSlideHeight,
|
||||
// Don't animate from zero to the height of the initial slide
|
||||
immediate: !previousSlideHeight,
|
||||
});
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
// Update slide height when the component mounts
|
||||
if (currentSlideHeight === 0) {
|
||||
handleSlideChange(0);
|
||||
|
@ -110,11 +124,15 @@ export const FeaturedCarousel: React.FC<{
|
|||
className='featured-carousel'
|
||||
{...bind()}
|
||||
aria-roledescription='carousel'
|
||||
aria-labelledby='featured-carousel-title'
|
||||
aria-labelledby={`${accessibilityId}-title`}
|
||||
role='region'
|
||||
>
|
||||
<div className='featured-carousel__header'>
|
||||
<h4 className='featured-carousel__title' id='featured-carousel-title'>
|
||||
<h4
|
||||
className='featured-carousel__title'
|
||||
id={`${accessibilityId}-title`}
|
||||
>
|
||||
<Icon id='thumb-tack' icon={PushPinIcon} />
|
||||
<FormattedMessage
|
||||
id='featured_carousel.header'
|
||||
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
||||
|
|
|
@ -45,6 +45,19 @@ export const HoverCardAccount = forwardRef<
|
|||
|
||||
const { familiarFollowers } = useFetchFamiliarFollowers({ accountId });
|
||||
|
||||
const relationship = useAppSelector((state) =>
|
||||
accountId ? state.relationships.get(accountId) : undefined,
|
||||
);
|
||||
const isMutual = relationship?.followed_by && relationship.following;
|
||||
const isFollower = relationship?.followed_by;
|
||||
const hasRelationshipLoaded = !!relationship;
|
||||
|
||||
const shouldDisplayFamiliarFollowers =
|
||||
familiarFollowers.length > 0 &&
|
||||
hasRelationshipLoaded &&
|
||||
!isMutual &&
|
||||
!isFollower;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
@ -86,7 +99,7 @@ export const HoverCardAccount = forwardRef<
|
|||
renderer={FollowersCounter}
|
||||
isHide={account.other_settings.hide_followers_count}
|
||||
/>
|
||||
{familiarFollowers.length > 0 && (
|
||||
{shouldDisplayFamiliarFollowers && (
|
||||
<>
|
||||
·
|
||||
<div className='hover-card__familiar-followers'>
|
||||
|
@ -102,6 +115,22 @@ export const HoverCardAccount = forwardRef<
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{(isMutual || isFollower) && (
|
||||
<>
|
||||
·
|
||||
{isMutual ? (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={accountId} />
|
||||
|
|
|
@ -28,6 +28,7 @@ interface Props {
|
|||
href?: string;
|
||||
ariaHidden?: boolean;
|
||||
data_id?: string;
|
||||
ariaControls?: string;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
|
@ -54,6 +55,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
|||
tabIndex = 0,
|
||||
ariaHidden = false,
|
||||
data_id = undefined,
|
||||
ariaControls,
|
||||
},
|
||||
buttonRef,
|
||||
) => {
|
||||
|
@ -155,6 +157,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
|||
aria-label={title}
|
||||
aria-expanded={expanded}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-controls={ariaControls}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -7,7 +7,7 @@ interface Props {
|
|||
id: string;
|
||||
icon: IconProp;
|
||||
count: number;
|
||||
issueBadge: boolean;
|
||||
issueBadge?: boolean;
|
||||
className: string;
|
||||
}
|
||||
export const IconWithBadge: React.FC<Props> = ({
|
||||
|
|
|
@ -14,7 +14,6 @@ import { fetchPoll, vote } from 'mastodon/actions/polls';
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
||||
import type * as Model from 'mastodon/models/poll';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
|
@ -265,7 +264,6 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||
to: {
|
||||
width: `${percent}%`,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -624,11 +624,11 @@ class Status extends ImmutablePureComponent {
|
|||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{children}
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
{emojiReactionsBar}
|
||||
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ interface BaseRule {
|
|||
|
||||
interface Rule extends BaseRule {
|
||||
id: string;
|
||||
translations: Record<string, BaseRule>;
|
||||
translations?: Record<string, BaseRule>;
|
||||
}
|
||||
|
||||
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
|
@ -113,15 +113,23 @@ const rulesSelector = createSelector(
|
|||
(rules, locale): Rule[] => {
|
||||
return rules.map((rule) => {
|
||||
const translations = rule.translations;
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
|
||||
// Handle cached responses from earlier versions
|
||||
if (!translations) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const partialLocale = locale.split('-')[0];
|
||||
if (partialLocale && translations[partialLocale]) {
|
||||
rule.text = translations[partialLocale].text;
|
||||
rule.hint = translations[partialLocale].hint;
|
||||
}
|
||||
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
}
|
||||
|
||||
return rule;
|
||||
});
|
||||
},
|
||||
|
|
|
@ -1,173 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { is } from 'immutable';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
||||
});
|
||||
|
||||
class InlineAlert extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
show: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
mountMessage: false,
|
||||
};
|
||||
|
||||
static TRANSITION_DELAY = 200;
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!this.props.show && nextProps.show) {
|
||||
this.setState({ mountMessage: true });
|
||||
} else if (this.props.show && !nextProps.show) {
|
||||
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { show } = this.props;
|
||||
const { mountMessage } = this.state;
|
||||
|
||||
return (
|
||||
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
||||
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AccountNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
value: null,
|
||||
saving: false,
|
||||
saved: false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
|
||||
const newState = {};
|
||||
|
||||
if (accountWillChange && this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
|
||||
if (accountWillChange || nextProps.value === this.state.value) {
|
||||
newState.saving = false;
|
||||
}
|
||||
|
||||
if (this.props.value !== nextProps.value) {
|
||||
newState.value = nextProps.value;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
}
|
||||
|
||||
setTextareaRef = c => {
|
||||
this.textarea = c;
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.setState({ value: e.target.value, saving: false });
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
} else {
|
||||
this._save();
|
||||
}
|
||||
} else if (e.keyCode === 27) {
|
||||
e.preventDefault();
|
||||
|
||||
this._reset(() => {
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
if (this._isDirty()) {
|
||||
this._save();
|
||||
}
|
||||
};
|
||||
|
||||
_save (showMessage = true) {
|
||||
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
||||
|
||||
if (showMessage) {
|
||||
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
||||
}
|
||||
}
|
||||
|
||||
_reset (callback) {
|
||||
this.setState({ value: this.props.value }, callback);
|
||||
}
|
||||
|
||||
_isDirty () {
|
||||
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountId, intl } = this.props;
|
||||
const { value, saved } = this.state;
|
||||
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${accountId}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
id={`account-note-${accountId}`}
|
||||
className='account__header__account-note__content'
|
||||
disabled={this.props.value === null || value === null}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value || ''}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setTextareaRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(AccountNote);
|
|
@ -0,0 +1,131 @@
|
|||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { submitAccountNote } from '@/mastodon/actions/account_notes';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'account_note.placeholder',
|
||||
defaultMessage: 'Click to add a note',
|
||||
},
|
||||
});
|
||||
|
||||
const AccountNoteUI: React.FC<{
|
||||
initialValue: string | undefined;
|
||||
onSubmit: (newNote: string) => void;
|
||||
wasSaved: boolean;
|
||||
}> = ({ initialValue, onSubmit, wasSaved }) => {
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
const isLoading = initialValue === undefined;
|
||||
const canSubmitOnBlurRef = useRef(true);
|
||||
|
||||
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
||||
setValue(initialValue ?? '');
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
onSubmit(value);
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[initialValue, onSubmit, value],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (initialValue !== value && canSubmitOnBlurRef.current) {
|
||||
onSubmit(value);
|
||||
}
|
||||
canSubmitOnBlurRef.current = true;
|
||||
}, [initialValue, onSubmit, value]);
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${uniqueId}`}>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>{' '}
|
||||
<span
|
||||
aria-live='polite'
|
||||
role='status'
|
||||
className='inline-alert'
|
||||
style={{ opacity: wasSaved ? 1 : 0 }}
|
||||
>
|
||||
{wasSaved && (
|
||||
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<div className='account__header__account-note__loading-indicator-wrapper'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
id={`account-note-${uniqueId}`}
|
||||
className='account__header__account-note__content'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountNote: React.FC<{
|
||||
accountId: string;
|
||||
}> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const initialValue = useAppSelector((state) =>
|
||||
state.relationships.get(accountId)?.get('note'),
|
||||
);
|
||||
const [wasSaved, setWasSaved] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(note: string) => {
|
||||
setWasSaved(true);
|
||||
void dispatch(submitAccountNote({ accountId, note }));
|
||||
|
||||
setTimeout(() => {
|
||||
setWasSaved(false);
|
||||
}, 2000);
|
||||
},
|
||||
[dispatch, accountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountNoteUI
|
||||
key={`${accountId}-${initialValue}`}
|
||||
initialValue={initialValue}
|
||||
wasSaved={wasSaved}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,19 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { submitAccountNote } from 'mastodon/actions/account_notes';
|
||||
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
value: state.relationships.getIn([accountId, 'note']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
|
||||
onSave (value) {
|
||||
dispatch(submitAccountNote({ accountId: accountId, note: value }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
|
@ -52,8 +52,8 @@ import { getFeaturedHashtagBar } from 'mastodon/components/hashtag_bar';
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { AccountNote } from 'mastodon/features/account/components/account_note';
|
||||
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';
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
@ -490,7 +490,7 @@ export const AccountHeader: React.FC<{
|
|||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && account.id !== me && !account.suspended) {
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
|
@ -514,37 +514,7 @@ export const AccountHeader: React.FC<{
|
|||
arr.push(null);
|
||||
}
|
||||
|
||||
if (account.id === me) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.edit_profile),
|
||||
href: '/settings/profile',
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
});
|
||||
arr.push(null);
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
to: '/follow_requests',
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.favourites),
|
||||
to: '/favourites',
|
||||
});
|
||||
arr.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.followed_tags),
|
||||
to: '/followed_tags',
|
||||
});
|
||||
arr.push(null);
|
||||
arr.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
arr.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.domain_blocks),
|
||||
to: '/domain_blocks',
|
||||
});
|
||||
} else if (signedIn) {
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
|
@ -697,8 +667,7 @@ export const AccountHeader: React.FC<{
|
|||
}
|
||||
|
||||
if (
|
||||
(account.id !== me &&
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) ||
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
|
@ -969,19 +938,21 @@ export const AccountHeader: React.FC<{
|
|||
>
|
||||
<Avatar
|
||||
account={suspended || hidden ? undefined : account}
|
||||
size={90}
|
||||
size={92}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
{accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
)}
|
||||
{!hidden && actionBtn}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1008,7 +979,7 @@ export const AccountHeader: React.FC<{
|
|||
<div className='account__header__badges'>{badges}</div>
|
||||
)}
|
||||
|
||||
{account.id !== me && signedIn && (
|
||||
{account.id !== me && signedIn && !(suspended || hidden) && (
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
|
@ -1019,7 +990,7 @@ export const AccountHeader: React.FC<{
|
|||
onClickCapture={handleLinkClick}
|
||||
>
|
||||
{account.id !== me && signedIn && (
|
||||
<AccountNoteContainer accountId={accountId} />
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAccountsFamiliarFollowers } from '@/mastodon/actions/accounts_familiar_followers';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { getAccountFamiliarFollowers } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
@ -14,14 +15,15 @@ export const useFetchFamiliarFollowers = ({
|
|||
const familiarFollowers = useAppSelector((state) =>
|
||||
accountId ? getAccountFamiliarFollowers(state, accountId) : null,
|
||||
);
|
||||
const { signedIn } = useIdentity();
|
||||
|
||||
const hasNoData = familiarFollowers === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNoData && accountId && accountId !== me) {
|
||||
if (hasNoData && signedIn && accountId && accountId !== me) {
|
||||
void dispatch(fetchAccountsFamiliarFollowers({ id: accountId }));
|
||||
}
|
||||
}, [dispatch, accountId, hasNoData]);
|
||||
}, [dispatch, accountId, hasNoData, signedIn]);
|
||||
|
||||
return {
|
||||
familiarFollowers: hasNoData ? [] : familiarFollowers,
|
||||
|
|
|
@ -137,7 +137,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl, params: { tagged } } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
|
@ -174,8 +174,8 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
<StatusList
|
||||
prepend={
|
||||
<>
|
||||
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />
|
||||
<FeaturedCarousel accountId={this.props.accountId} />
|
||||
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={tagged} />
|
||||
{!forceEmptyState && <FeaturedCarousel accountId={this.props.accountId} tagged={tagged} />}
|
||||
</>
|
||||
}
|
||||
alwaysPrepend
|
||||
|
|
|
@ -27,7 +27,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, reduceMotion } from 'mastodon/initial_state';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
@ -110,7 +110,7 @@ const Preview: React.FC<{
|
|||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
},
|
||||
immediate: reduceMotion || draggingRef.current,
|
||||
immediate: draggingRef.current,
|
||||
});
|
||||
const media = useAppSelector((state) =>
|
||||
(
|
||||
|
|
|
@ -17,12 +17,9 @@ import { Blurhash } from 'mastodon/components/blurhash';
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition } from 'mastodon/features/video';
|
||||
import { useAudioContext } from 'mastodon/hooks/useAudioContext';
|
||||
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
||||
import {
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
reduceMotion,
|
||||
} from 'mastodon/initial_state';
|
||||
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||
import { playerSettings } from 'mastodon/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -119,12 +116,17 @@ export const Audio: React.FC<{
|
|||
const seekRef = useRef<HTMLDivElement>(null);
|
||||
const volumeRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
||||
audioRef,
|
||||
3,
|
||||
);
|
||||
const accessibilityId = useId();
|
||||
|
||||
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
|
||||
useAudioContext({ audioElementRef: audioRef });
|
||||
|
||||
const frequencyBands = useAudioVisualizer({
|
||||
audioContextRef,
|
||||
sourceRef,
|
||||
numBands: 3,
|
||||
});
|
||||
|
||||
const [style, spring] = useSpring(() => ({
|
||||
progress: '0%',
|
||||
buffer: '0%',
|
||||
|
@ -152,22 +154,23 @@ export const Audio: React.FC<{
|
|||
restoreVolume(audioRef.current);
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = audioRef.current.volume;
|
||||
}
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
spring,
|
||||
setVolume,
|
||||
setMuted,
|
||||
deployPictureInPicture,
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
foregroundColor,
|
||||
deployPictureInPicture,
|
||||
accentColor,
|
||||
gainNodeRef,
|
||||
spring,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -178,7 +181,11 @@ export const Audio: React.FC<{
|
|||
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.muted = muted;
|
||||
}, [volume, muted]);
|
||||
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = muted ? 0 : volume;
|
||||
}
|
||||
}, [volume, muted, gainNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof visible !== 'undefined') {
|
||||
|
@ -192,11 +199,10 @@ export const Audio: React.FC<{
|
|||
}, [visible, sensitive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
if (!revealed) {
|
||||
pauseAudio();
|
||||
}
|
||||
}, [suspendAudio, revealed]);
|
||||
}, [pauseAudio, revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
||||
|
@ -206,7 +212,6 @@ export const Audio: React.FC<{
|
|||
if (audioRef.current && audioRef.current.duration > 0) {
|
||||
void spring.start({
|
||||
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
config: config.stiff,
|
||||
});
|
||||
}
|
||||
|
@ -228,13 +233,11 @@ export const Audio: React.FC<{
|
|||
}
|
||||
|
||||
if (audioRef.current.paused) {
|
||||
resumeAudio();
|
||||
void audioRef.current.play();
|
||||
playAudio();
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
pauseAudio();
|
||||
}
|
||||
}, [resumeAudio, suspendAudio]);
|
||||
}, [playAudio, pauseAudio]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
setPaused(false);
|
||||
|
@ -254,7 +257,6 @@ export const Audio: React.FC<{
|
|||
if (lastTimeRange > -1) {
|
||||
void spring.start({
|
||||
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
}, [spring]);
|
||||
|
@ -269,7 +271,6 @@ export const Audio: React.FC<{
|
|||
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
||||
|
@ -349,8 +350,7 @@ export const Audio: React.FC<{
|
|||
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
resumeAudio();
|
||||
void audioRef.current?.play();
|
||||
playAudio();
|
||||
};
|
||||
|
||||
const handleSeekMouseMove = (e: MouseEvent) => {
|
||||
|
@ -377,7 +377,7 @@ export const Audio: React.FC<{
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[setDragging, spring, resumeAudio],
|
||||
[playAudio, spring],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
|
@ -442,11 +442,13 @@ export const Audio: React.FC<{
|
|||
if (typeof startMuted !== 'undefined') {
|
||||
audioRef.current.muted = startMuted;
|
||||
}
|
||||
}, [setDuration, startTime, startVolume, startMuted]);
|
||||
|
||||
const handleCanPlayThrough = useCallback(() => {
|
||||
if (startPlaying) {
|
||||
void audioRef.current.play();
|
||||
playAudio();
|
||||
}
|
||||
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
|
||||
}, [startPlaying, playAudio]);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (!audioRef.current) {
|
||||
|
@ -489,7 +491,7 @@ export const Audio: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
const newVolume = audioRef.current.volume + step;
|
||||
const newVolume = Math.max(0, audioRef.current.volume + step);
|
||||
|
||||
if (!isNaN(newVolume)) {
|
||||
audioRef.current.volume = newVolume;
|
||||
|
@ -591,6 +593,7 @@ export const Audio: React.FC<{
|
|||
onPause={handlePause}
|
||||
onProgress={handleProgress}
|
||||
onLoadedData={handleLoadedData}
|
||||
onCanPlayThrough={handleCanPlayThrough}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
crossOrigin='anonymous'
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
reaction_deck: {
|
||||
id: 'navigation_bar.reaction_deck',
|
||||
defaultMessage: 'Reaction deck',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
emoji_reactions: {
|
||||
id: 'navigation_bar.emoji_reactions',
|
||||
defaultMessage: 'Stamps',
|
||||
},
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
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',
|
||||
},
|
||||
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: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const handleLogoutClick = () => {
|
||||
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',
|
||||
},
|
||||
null,
|
||||
{
|
||||
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.emoji_reactions),
|
||||
to: '/emoji_reactions',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.reaction_deck),
|
||||
to: '/reaction_deck',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.lists), to: '/lists' },
|
||||
{
|
||||
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.filters), href: '/filters' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
||||
];
|
||||
}, [intl, dispatch]);
|
||||
|
||||
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||
};
|
|
@ -2,34 +2,44 @@ import { useCallback } from 'react';
|
|||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { cancelReplyCompose } from 'mastodon/actions/compose';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
||||
import { ActionBar } from './action_bar';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
});
|
||||
|
||||
export const NavigationBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
export const NavigationBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
|
||||
const isReplying = useAppSelector(
|
||||
(state) => !!state.compose.get('in_reply_to'),
|
||||
);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
dispatch(cancelReplyCompose());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!me) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Account id={me} minimal />
|
||||
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
|
||||
|
||||
{isReplying && (
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.cancel)}
|
||||
icon=''
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleCancelClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState, useRef } from 'react';
|
||||
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
defineMessages,
|
||||
|
@ -72,6 +72,10 @@ export const Search: React.FC<{
|
|||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(-1);
|
||||
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
|
||||
useEffect(() => {
|
||||
setValue(initialValue ?? '');
|
||||
setQuickActions([]);
|
||||
}, [initialValue]);
|
||||
const searchOptions: SearchOption[] = [];
|
||||
|
||||
if (searchEnabled) {
|
||||
|
@ -263,7 +267,7 @@ export const Search: React.FC<{
|
|||
},
|
||||
forget: (e) => {
|
||||
e.stopPropagation();
|
||||
void dispatch(forgetSearchResult(search.q));
|
||||
void dispatch(forgetSearchResult(search));
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -539,8 +543,10 @@ export const Search: React.FC<{
|
|||
<div className='search__popout__menu'>
|
||||
{recentOptions.length > 0 ? (
|
||||
recentOptions.map(({ label, key, action, forget }, i) => (
|
||||
<button
|
||||
<div
|
||||
key={key}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onMouseDown={action}
|
||||
className={classNames(
|
||||
'search__popout__menu__item search__popout__menu__item--flex',
|
||||
|
@ -551,7 +557,7 @@ export const Search: React.FC<{
|
|||
<button className='icon-button' onMouseDown={forget}>
|
||||
<Icon id='times' icon={CloseIcon} />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
|
|
|
@ -4,7 +4,6 @@ import { animated, useSpring } from '@react-spring/web';
|
|||
|
||||
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
|
||||
interface UploadProgressProps {
|
||||
active: boolean;
|
||||
|
@ -20,7 +19,7 @@ export const UploadProgress: React.FC<UploadProgressProps> = ({
|
|||
const styles = useSpring({
|
||||
from: { width: '0%' },
|
||||
to: { width: `${progress}%` },
|
||||
immediate: reduceMotion || !active, // If this is not active, update the UI immediately.
|
||||
immediate: !active, // If this is not active, update the UI immediately.
|
||||
});
|
||||
if (!active) {
|
||||
return null;
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { mascot } from '../../initial_state';
|
||||
import { isMobile } from '../../is_mobile';
|
||||
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
|
||||
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
});
|
||||
|
||||
class Compose extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
columns: ImmutablePropTypes.list.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(mountCompose());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(unmountCompose());
|
||||
}
|
||||
|
||||
handleLogoutClick = e => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
this.props.dispatch(changeComposing(true));
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
this.props.dispatch(changeComposing(false));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { multiColumn, intl } = this.props;
|
||||
|
||||
if (multiColumn) {
|
||||
const { columns } = this.props;
|
||||
|
||||
return (
|
||||
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
|
||||
<nav className='drawer__header'>
|
||||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link>
|
||||
{!columns.some(column => column.get('id') === 'HOME') && (
|
||||
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
|
||||
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' icon={NotificationsIcon} /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
|
||||
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link>
|
||||
)}
|
||||
{!columns.some(column => column.get('id') === 'PUBLIC') && (
|
||||
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' icon={SettingsIcon} /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
||||
</nav>
|
||||
|
||||
{multiColumn && <Search /> }
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
|
||||
|
||||
<div className='drawer__inner__mastodon'>
|
||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column onFocus={this.onFocus}>
|
||||
<ComposeFormContainer />
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Compose));
|
200
app/javascript/mastodon/features/compose/index.tsx
Normal file
200
app/javascript/mastodon/features/compose/index.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { mascot } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: {
|
||||
id: 'tabs_bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
public: {
|
||||
id: 'navigation_bar.public_timeline',
|
||||
defaultMessage: 'Federated timeline',
|
||||
},
|
||||
community: {
|
||||
id: 'navigation_bar.community_timeline',
|
||||
defaultMessage: 'Local timeline',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
|
||||
|
||||
const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const columns = useAppSelector(
|
||||
(state) =>
|
||||
(state.settings as ImmutableMap<string, unknown>).get(
|
||||
'columns',
|
||||
) as ImmutableList<ColumnMap>,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(mountCompose());
|
||||
|
||||
return () => {
|
||||
dispatch(unmountCompose());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLogoutClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
|
||||
return false;
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (multiColumn) {
|
||||
return (
|
||||
<div
|
||||
className='drawer'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage(messages.compose)}
|
||||
>
|
||||
<nav className='drawer__header'>
|
||||
<Link
|
||||
to='/getting-started'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.start)}
|
||||
aria-label={intl.formatMessage(messages.start)}
|
||||
>
|
||||
<Icon id='bars' icon={MenuIcon} />
|
||||
</Link>
|
||||
{!columns.some((column) => column.get('id') === 'HOME') && (
|
||||
<Link
|
||||
to='/home'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.home_timeline)}
|
||||
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</Link>
|
||||
)}
|
||||
{!columns.some((column) => column.get('id') === 'NOTIFICATIONS') && (
|
||||
<Link
|
||||
to='/notifications'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.notifications)}
|
||||
aria-label={intl.formatMessage(messages.notifications)}
|
||||
>
|
||||
<Icon id='bell' icon={NotificationsIcon} />
|
||||
</Link>
|
||||
)}
|
||||
{!columns.some((column) => column.get('id') === 'COMMUNITY') && (
|
||||
<Link
|
||||
to='/public/local'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.community)}
|
||||
aria-label={intl.formatMessage(messages.community)}
|
||||
>
|
||||
<Icon id='users' icon={PeopleIcon} />
|
||||
</Link>
|
||||
)}
|
||||
{!columns.some((column) => column.get('id') === 'PUBLIC') && (
|
||||
<Link
|
||||
to='/public'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.public)}
|
||||
aria-label={intl.formatMessage(messages.public)}
|
||||
>
|
||||
<Icon id='globe' icon={PublicIcon} />
|
||||
</Link>
|
||||
)}
|
||||
<a
|
||||
href='/settings/preferences'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.preferences)}
|
||||
aria-label={intl.formatMessage(messages.preferences)}
|
||||
>
|
||||
<Icon id='cog' icon={SettingsIcon} />
|
||||
</a>
|
||||
<a
|
||||
href='/auth/sign_out'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.logout)}
|
||||
aria-label={intl.formatMessage(messages.logout)}
|
||||
onClick={handleLogoutClick}
|
||||
>
|
||||
<Icon id='sign-out' icon={LogoutIcon} />
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<Search singleColumn={false} />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner'>
|
||||
<ComposeFormContainer />
|
||||
|
||||
<div className='drawer__inner__mastodon'>
|
||||
<img alt='' draggable='false' src={mascot ?? elephantUIPlane} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.compose)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
title={intl.formatMessage(messages.compose)}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<div className='scrollable'>
|
||||
<ComposeFormContainer />
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Compose;
|
|
@ -1,75 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||
});
|
||||
|
||||
export const Card = ({ id, source }) => {
|
||||
const intl = useIntl();
|
||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
let label;
|
||||
|
||||
switch (source) {
|
||||
case 'friends_of_friends':
|
||||
label = <FormattedMessage id='follow_suggestions.friends_of_friends_longer' defaultMessage='Popular among people you follow' />;
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
label = <FormattedMessage id='follow_suggestions.similar_to_recently_followed_longer' defaultMessage='Similar to profiles you recently followed' />;
|
||||
break;
|
||||
case 'featured':
|
||||
label = <FormattedMessage id='follow_suggestions.featured_longer' defaultMessage='Hand-picked by the {domain} team' values={{ domain }} />;
|
||||
break;
|
||||
case 'most_followed':
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion_longer' defaultMessage='Popular on {domain}' values={{ domain }} />;
|
||||
break;
|
||||
case 'most_interactions':
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion_longer' defaultMessage='Popular on {domain}' values={{ domain }} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='explore__suggestions__card'>
|
||||
<div className='explore__suggestions__card__source'>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div className='explore__suggestions__card__body'>
|
||||
<Link to={`/@${account.get('acct')}`} data-hover-card-account={account.id}><Avatar account={account} size={48} /></Link>
|
||||
|
||||
<div className='explore__suggestions__card__body__main'>
|
||||
<div className='explore__suggestions__card__body__main__name-button'>
|
||||
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`} data-hover-card-account={account.id}><DisplayName account={account} /></Link>
|
||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<FollowButton accountId={account.get('id')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
source: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
|
||||
};
|
124
app/javascript/mastodon/features/explore/components/card.tsx
Normal file
124
app/javascript/mastodon/features/explore/components/card.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
dismiss: {
|
||||
id: 'follow_suggestions.dismiss',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
});
|
||||
|
||||
type SuggestionSource =
|
||||
| 'friends_of_friends'
|
||||
| 'similar_to_recently_followed'
|
||||
| 'featured'
|
||||
| 'most_followed'
|
||||
| 'most_interactions';
|
||||
|
||||
export const Card: React.FC<{ id: string; source: SuggestionSource }> = ({
|
||||
id,
|
||||
source,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
let label;
|
||||
|
||||
switch (source) {
|
||||
case 'friends_of_friends':
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.friends_of_friends_longer'
|
||||
defaultMessage='Popular among people you follow'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.similar_to_recently_followed_longer'
|
||||
defaultMessage='Similar to profiles you recently followed'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'featured':
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.featured_longer'
|
||||
defaultMessage='Hand-picked by the {domain} team'
|
||||
values={{ domain }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_followed':
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion_longer'
|
||||
defaultMessage='Popular on {domain}'
|
||||
values={{ domain }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_interactions':
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion_longer'
|
||||
defaultMessage='Popular on {domain}'
|
||||
values={{ domain }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='explore-suggestions-card'>
|
||||
<div className='explore-suggestions-card__source'>{label}</div>
|
||||
|
||||
<div className='explore-suggestions-card__body'>
|
||||
<Link
|
||||
to={`/@${account.get('acct')}`}
|
||||
data-hover-card-account={account.id}
|
||||
className='explore-suggestions-card__link'
|
||||
>
|
||||
<Avatar
|
||||
account={account}
|
||||
size={48}
|
||||
className='explore-suggestions-card__avatar'
|
||||
/>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
<div className='explore-suggestions-card__actions'>
|
||||
<IconButton
|
||||
icon='close'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleDismiss}
|
||||
title={intl.formatMessage(messages.dismiss)}
|
||||
className='explore-suggestions-card__dismiss-button'
|
||||
/>
|
||||
<FollowButton accountId={account.get('id')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -66,7 +66,7 @@ export const Story = ({
|
|||
<a className='story__thumbnail' href={url} target='blank' rel='noopener'>
|
||||
{thumbnail ? (
|
||||
<>
|
||||
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
|
||||
{!thumbnailLoaded && <Blurhash hash={blurhash} className='story__thumbnail__preview' />}
|
||||
<img src={thumbnail} onLoad={handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} />
|
||||
</>
|
||||
) : <Skeleton />}
|
||||
|
|
|
@ -9,7 +9,9 @@ import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
|||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { SymbolLogo } from 'mastodon/components/logo';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import Links from './links';
|
||||
|
@ -25,6 +27,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
const { signedIn } = useIdentity();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const logoRequired = useBreakpoint('full');
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
|
@ -38,7 +41,7 @@ const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
>
|
||||
<ColumnHeader
|
||||
icon={'explore'}
|
||||
iconComponent={ExploreIcon}
|
||||
iconComponent={logoRequired ? SymbolLogo : ExploreIcon}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
|
|
|
@ -270,7 +270,6 @@ const ReactionsBar = ({
|
|||
leave: {
|
||||
scale: 0,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
keys: visibleReactions.map(x => x.get('name')),
|
||||
});
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
|||
|
||||
import { dtlTag, enableDtlMenu, me, showTrends } from '../../initial_state';
|
||||
import { NavigationBar } from '../compose/components/navigation_bar';
|
||||
import ColumnLink from '../ui/components/column_link';
|
||||
import { ColumnLink } from '../ui/components/column_link';
|
||||
import ColumnSubheading from '../ui/components/column_subheading';
|
||||
|
||||
import TrendsContainer from './containers/trends_container';
|
||||
|
|
|
@ -40,6 +40,19 @@ export const ColumnSettings: React.FC = () => {
|
|||
}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
prefix='home_timeline'
|
||||
settings={settings}
|
||||
settingPath={['shows', 'quote']}
|
||||
onChange={onChange}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='home.column_settings.show_quotes'
|
||||
defaultMessage='Show quotes'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
prefix='home_timeline'
|
||||
settings={settings}
|
||||
|
|
|
@ -10,12 +10,14 @@ import { connect } from 'react-redux';
|
|||
|
||||
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import { SymbolLogo } from 'mastodon/components/logo';
|
||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
||||
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
|
@ -52,6 +54,7 @@ class HomeTimeline extends PureComponent {
|
|||
hasAnnouncements: PropTypes.bool,
|
||||
unreadAnnouncements: PropTypes.number,
|
||||
showAnnouncements: PropTypes.bool,
|
||||
matchesBreakpoint: PropTypes.bool,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
|
@ -121,7 +124,7 @@ class HomeTimeline extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.props.identity;
|
||||
const banners = [];
|
||||
|
@ -150,7 +153,7 @@ class HomeTimeline extends PureComponent {
|
|||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
iconComponent={matchesBreakpoint ? SymbolLogo : HomeIcon}
|
||||
active={hasUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
|
@ -187,4 +190,4 @@ class HomeTimeline extends PureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(withIdentity(injectIntl(HomeTimeline)));
|
||||
export default connect(mapStateToProps)(withBreakpoint(withIdentity(injectIntl(HomeTimeline))));
|
||||
|
|
|
@ -170,7 +170,7 @@ export const Follows: React.FC<{
|
|||
}
|
||||
>
|
||||
{displayedAccountIds.map((accountId) => (
|
||||
<Account id={accountId} key={accountId} withBio />
|
||||
<Account id={accountId} key={accountId} withBio withMenu={false} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
|
|
|
@ -452,13 +452,13 @@ export const DetailedStatus: React.FC<{
|
|||
{...(statusContentProps as any)}
|
||||
/>
|
||||
|
||||
{status.get('quote') && (
|
||||
<QuotedStatus quote={status.get('quote')} />
|
||||
)}
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
{emojiReactionsBar}
|
||||
|
||||
{status.get('quote') && (
|
||||
<QuotedStatus quote={status.get('quote')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, href, method, badge, transparent, optional, children, ...other }) => {
|
||||
const match = useRouteMatch(to);
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional });
|
||||
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
|
||||
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
|
||||
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
|
||||
const active = match?.isExact;
|
||||
const childElement = typeof children !== 'undefined' ? <p>{children}</p> : null;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={className} data-method={method} {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink to={to} className={className} exact {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
{childElement}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ColumnLink.propTypes = {
|
||||
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
iconComponent: PropTypes.func,
|
||||
activeIcon: PropTypes.node,
|
||||
activeIconComponent: PropTypes.func,
|
||||
text: PropTypes.string.isRequired,
|
||||
to: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
method: PropTypes.string,
|
||||
badge: PropTypes.node,
|
||||
transparent: PropTypes.bool,
|
||||
children: PropTypes.any,
|
||||
optional: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ColumnLink;
|
109
app/javascript/mastodon/features/ui/components/column_link.tsx
Normal file
109
app/javascript/mastodon/features/ui/components/column_link.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
|
||||
export const ColumnLink: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
iconComponent?: IconProp;
|
||||
activeIcon?: React.ReactNode;
|
||||
activeIconComponent?: IconProp;
|
||||
isActive?: (match: unknown, location: { pathname: string }) => boolean;
|
||||
text: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
method?: string;
|
||||
badge?: React.ReactNode;
|
||||
transparent?: boolean;
|
||||
optional?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}> = ({
|
||||
icon,
|
||||
activeIcon,
|
||||
iconComponent,
|
||||
activeIconComponent,
|
||||
text,
|
||||
to,
|
||||
href,
|
||||
onClick,
|
||||
method,
|
||||
badge,
|
||||
transparent,
|
||||
optional,
|
||||
children,
|
||||
...other
|
||||
}) => {
|
||||
const match = useRouteMatch(to ?? '');
|
||||
const className = classNames('column-link', {
|
||||
'column-link--transparent': transparent,
|
||||
'column-link--optional': optional,
|
||||
});
|
||||
const badgeElement =
|
||||
typeof badge !== 'undefined' ? (
|
||||
<span className='column-link__badge'>{badge}</span>
|
||||
) : null;
|
||||
const iconElement = iconComponent ? (
|
||||
<Icon
|
||||
id={typeof icon === 'string' ? icon : ''}
|
||||
icon={iconComponent}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
);
|
||||
const activeIconElement =
|
||||
activeIcon ??
|
||||
(activeIconComponent ? (
|
||||
<Icon
|
||||
id={typeof icon === 'string' ? icon : ''}
|
||||
icon={activeIconComponent}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
) : (
|
||||
iconElement
|
||||
));
|
||||
const active = !!match;
|
||||
const childElement = typeof children !== 'undefined' ? <p>{children}</p> : null;
|
||||
|
||||
const handleClick = useCallback((ev: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
ev.preventDefault();
|
||||
onClick?.();
|
||||
}, [onClick]);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={className} data-method={method} {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
{childElement}
|
||||
</a>
|
||||
);
|
||||
} else if (to) {
|
||||
return (
|
||||
<NavLink to={to} className={className} {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
{childElement}
|
||||
</NavLink>
|
||||
);
|
||||
} else if (onClick) {
|
||||
return (
|
||||
<a href={href} className={className} onClick={handleClick} {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
{childElement}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -28,9 +28,9 @@ import { useColumnsContext } from '../util/columns_context';
|
|||
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
import { ColumnLoading } from './column_loading';
|
||||
import ComposePanel from './compose_panel';
|
||||
import { ComposePanel } from './compose_panel';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
import NavigationPanel from './navigation_panel';
|
||||
import { NavigationPanel } from './navigation_panel';
|
||||
|
||||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
|
@ -142,11 +142,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
</div>
|
||||
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
|
||||
<div className='columns-area__panels__pane__inner'>
|
||||
<NavigationPanel />
|
||||
</div>
|
||||
</div>
|
||||
<NavigationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
class ComposePanel extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(changeComposing(true));
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(changeComposing(false));
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(mountCompose());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(unmountCompose());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
return (
|
||||
<div className='compose-panel' onFocus={this.onFocus}>
|
||||
<Search openInRoute />
|
||||
|
||||
{!signedIn && (
|
||||
<>
|
||||
<ServerBanner />
|
||||
<div className='flex-spacer' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<ComposeFormContainer singleColumn />
|
||||
)}
|
||||
|
||||
<LinkFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(withIdentity(ComposePanel));
|
|
@ -0,0 +1,56 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import {
|
||||
changeComposing,
|
||||
mountCompose,
|
||||
unmountCompose,
|
||||
} from 'mastodon/actions/compose';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
export const ComposePanel: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleFocus = useCallback(() => {
|
||||
dispatch(changeComposing(true));
|
||||
}, [dispatch]);
|
||||
const { signedIn } = useIdentity();
|
||||
const hideComposer = useAppSelector((state) => {
|
||||
const mounted = state.compose.get('mounted');
|
||||
if (typeof mounted === 'number') {
|
||||
return mounted > 1;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(mountCompose());
|
||||
return () => {
|
||||
dispatch(unmountCompose());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const { singleColumn } = useLayout();
|
||||
|
||||
return (
|
||||
<div className='compose-panel' onFocus={handleFocus}>
|
||||
<Search singleColumn={singleColumn} />
|
||||
|
||||
{!signedIn && (
|
||||
<>
|
||||
<ServerBanner />
|
||||
<div className='flex-spacer' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && !hideComposer && <ComposeFormContainer singleColumn />}
|
||||
{signedIn && hideComposer && <div className='compose-form' />}
|
||||
|
||||
<LinkFooter multiColumn={!singleColumn} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,129 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { registrationsOpen, me, sso_redirect } from 'mastodon/initial_state';
|
||||
|
||||
const Account = connect(state => ({
|
||||
account: state.getIn(['accounts', me]),
|
||||
}))(({ account }) => (
|
||||
<Link to={`/@${account.get('acct')}`} title={account.get('acct')}>
|
||||
<Avatar account={account} size={35} />
|
||||
</Link>
|
||||
));
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
reload: { id: 'navigation_bar.refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
openClosedRegistrationsModal() {
|
||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
||||
},
|
||||
dispatchServer() {
|
||||
dispatch(fetchServer());
|
||||
}
|
||||
});
|
||||
|
||||
class Header extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
openClosedRegistrationsModal: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
signupUrl: PropTypes.string.isRequired,
|
||||
dispatchServer: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatchServer } = this.props;
|
||||
dispatchServer();
|
||||
}
|
||||
|
||||
handleReload (e) {
|
||||
e.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { signedIn } = this.props.identity;
|
||||
const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props;
|
||||
|
||||
let content;
|
||||
|
||||
if (signedIn) {
|
||||
content = (
|
||||
<>
|
||||
{<button onClick={this.handleReload} className='button button-secondary' aria-label={intl.formatMessage(messages.reload)}><Icon id='refresh' icon={RefreshIcon} /></button>}
|
||||
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' icon={SearchIcon} /></Link>}
|
||||
{location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>}
|
||||
<Account />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
|
||||
if (sso_redirect) {
|
||||
content = (
|
||||
<a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a>
|
||||
);
|
||||
} else {
|
||||
let signupButton;
|
||||
|
||||
if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='button'>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='button' onClick={openClosedRegistrationsModal}>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
content = (
|
||||
<>
|
||||
{signupButton}
|
||||
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='ui__header'>
|
||||
<Link to='/' className='ui__header__logo'>
|
||||
<WordmarkLogo />
|
||||
<SymbolLogo />
|
||||
</Link>
|
||||
|
||||
<div className='ui__header__links'>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(withRouter(withIdentity(connect(mapStateToProps, mapDispatchToProps)(Header))));
|
|
@ -1,57 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
|
||||
import { fetchAntennas } from 'mastodon/actions/antennas';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
|
||||
import ColumnLink from './column_link';
|
||||
|
||||
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item && item.get('favourite')).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
|
||||
});
|
||||
|
||||
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
|
||||
if (!antennas) {
|
||||
return antennas;
|
||||
}
|
||||
|
||||
return antennas.toList().filter(item => !!item && item.get('favourite') && item.get('title') !== undefined).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
|
||||
});
|
||||
|
||||
export const ListPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
const lists = useSelector(state => getOrderedLists(state));
|
||||
const antennas = useSelector(state => getOrderedAntennas(state));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLists());
|
||||
dispatch(fetchAntennas());
|
||||
}, [dispatch]);
|
||||
|
||||
const size = (lists ? lists.size : 0) + (antennas ? antennas.size : 0);
|
||||
if (size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='list-panel'>
|
||||
<hr />
|
||||
|
||||
{lists && lists.map(list => (
|
||||
<ColumnLink icon='list-ul' key={list.get('id')} iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
|
||||
))}
|
||||
{antennas && antennas.map(antenna => (
|
||||
<ColumnLink icon='wifi' key={antenna.get('id')} iconComponent={AntennaIcon} activeIconComponent={AntennaIcon} text={antenna.get('title')} to={`/antennas/${antenna.get('id')}`} transparent />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
152
app/javascript/mastodon/features/ui/components/list_panel.tsx
Normal file
152
app/javascript/mastodon/features/ui/components/list_panel.tsx
Normal file
|
@ -0,0 +1,152 @@
|
|||
import { useEffect, useState, useCallback, useId } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
|
||||
import ArrowLeftIcon from '@/material-icons/400-24px/arrow_left.svg?react';
|
||||
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
|
||||
import { fetchAntennas } from 'mastodon/actions/antennas';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { ColumnLink } from './column_link';
|
||||
|
||||
const messages = defineMessages({
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
|
||||
expand: {
|
||||
id: 'navigation_panel.expand_lists',
|
||||
defaultMessage: 'Expand list menu',
|
||||
},
|
||||
collapse: {
|
||||
id: 'navigation_panel.collapse_lists',
|
||||
defaultMessage: 'Collapse list menu',
|
||||
},
|
||||
});
|
||||
|
||||
export const ListPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const lists = useAppSelector((state) => getOrderedLists(state));
|
||||
const antennas = useAppSelector(state => getOrderedAntennas(state));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expandedAntenna, setExpandedAntenna] = useState(false);
|
||||
const accessibilityId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLists());
|
||||
dispatch(fetchAntennas());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded((value) => !value);
|
||||
}, [setExpanded]);
|
||||
|
||||
const handleClickAntenna = useCallback(() => {
|
||||
setExpandedAntenna((value) => !value);
|
||||
}, [setExpandedAntenna]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='navigation-panel__list-panel'>
|
||||
<div className='navigation-panel__list-panel__header'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/lists'
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
activeIconComponent={ListAltActiveIcon}
|
||||
text={intl.formatMessage(messages.lists)}
|
||||
id={`${accessibilityId}-title`}
|
||||
/>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<IconButton
|
||||
icon='down'
|
||||
expanded={expanded}
|
||||
iconComponent={expanded ? ArrowDropDownIcon : ArrowLeftIcon}
|
||||
title={intl.formatMessage(
|
||||
expanded ? messages.collapse : messages.expand,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
aria-controls={`${accessibilityId}-content`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lists.length > 0 && expanded && (
|
||||
<div
|
||||
className='navigation-panel__list-panel__items'
|
||||
role='region'
|
||||
id={`${accessibilityId}-content`}
|
||||
aria-labelledby={`${accessibilityId}-title`}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ColumnLink
|
||||
icon='list-ul'
|
||||
key={list.get('id')}
|
||||
iconComponent={ListAltIcon}
|
||||
activeIconComponent={ListAltActiveIcon}
|
||||
text={list.get('title')}
|
||||
to={`/lists/${list.get('id')}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='navigation-panel__list-panel'>
|
||||
<div className='navigation-panel__list-panel__header'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/antennas'
|
||||
icon='list-ul'
|
||||
iconComponent={AntennaIcon}
|
||||
activeIconComponent={AntennaIcon}
|
||||
text={intl.formatMessage(messages.antennas)}
|
||||
id={`${accessibilityId}-title`}
|
||||
/>
|
||||
|
||||
{antennas.length > 0 && (
|
||||
<IconButton
|
||||
icon='down'
|
||||
expanded={expanded}
|
||||
iconComponent={expanded ? ArrowDropDownIcon : ArrowLeftIcon}
|
||||
title={intl.formatMessage(
|
||||
expanded ? messages.collapse : messages.expand,
|
||||
)}
|
||||
onClick={handleClickAntenna}
|
||||
aria-controls={`${accessibilityId}-content`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{antennas.length > 0 && expandedAntenna && (
|
||||
<div
|
||||
className='navigation-panel__list-panel__items'
|
||||
role='region'
|
||||
id={`${accessibilityId}-content`}
|
||||
aria-labelledby={`${accessibilityId}-title`}
|
||||
>
|
||||
{antennas.map((antenna) => (
|
||||
<ColumnLink
|
||||
icon='list-ul'
|
||||
key={antenna.get('id')}
|
||||
iconComponent={AntennaIcon}
|
||||
activeIconComponent={AntennaIcon}
|
||||
text={antenna.get('title')}
|
||||
to={`/antennas/${antenna.get('id')}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -168,8 +168,8 @@ class MediaModal extends ImmutablePureComponent {
|
|||
|
||||
const index = this.getIndex();
|
||||
|
||||
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
|
||||
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--prev' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--next' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
|
||||
|
||||
const content = media.map((image, idx) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
|
|
148
app/javascript/mastodon/features/ui/components/more_link.tsx
Normal file
148
app/javascript/mastodon/features/ui/components/more_link.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { enableEmojiReaction } from '@/mastodon/initial_state';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
followedTags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domainBlocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
administration: {
|
||||
id: 'navigation_bar.administration',
|
||||
defaultMessage: 'Administration',
|
||||
},
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
automatedDeletion: {
|
||||
id: 'navigation_bar.automated_deletion',
|
||||
defaultMessage: 'Automated post deletion',
|
||||
},
|
||||
accountSettings: {
|
||||
id: 'navigation_bar.account_settings',
|
||||
defaultMessage: 'Password and security',
|
||||
},
|
||||
importExport: {
|
||||
id: 'navigation_bar.import_export',
|
||||
defaultMessage: 'Import and export',
|
||||
},
|
||||
privacyAndReach: {
|
||||
id: 'navigation_bar.privacy_and_reach',
|
||||
defaultMessage: 'Privacy and reach',
|
||||
},
|
||||
reaction_deck: {
|
||||
id: 'navigation_bar.reaction_deck',
|
||||
defaultMessage: 'Reaction deck',
|
||||
},
|
||||
emoji_reactions: {
|
||||
id: 'navigation_bar.emoji_reactions',
|
||||
defaultMessage: 'Emoji reactions',
|
||||
},
|
||||
});
|
||||
|
||||
export const MoreLink: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { permissions } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const emojiReactionMenu = useMemo(() => {
|
||||
if (!enableEmojiReaction) return [];
|
||||
return [{
|
||||
text: intl.formatMessage(messages.emoji_reactions),
|
||||
to: '/emoji_reactions',
|
||||
}];
|
||||
}, [enableEmojiReaction, intl]);
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: MenuItem[] = [
|
||||
{
|
||||
text: intl.formatMessage(messages.followedTags),
|
||||
to: '/followed_tags',
|
||||
},
|
||||
...emojiReactionMenu,
|
||||
{
|
||||
text: intl.formatMessage(messages.reaction_deck),
|
||||
to: '/reaction_deck',
|
||||
},
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
to: '/domain_blocks',
|
||||
},
|
||||
];
|
||||
|
||||
arr.push(
|
||||
null,
|
||||
{
|
||||
href: '/settings/privacy',
|
||||
text: intl.formatMessage(messages.privacyAndReach),
|
||||
},
|
||||
{
|
||||
href: '/statuses_cleanup',
|
||||
text: intl.formatMessage(messages.automatedDeletion),
|
||||
},
|
||||
{
|
||||
href: '/auth/edit',
|
||||
text: intl.formatMessage(messages.accountSettings),
|
||||
},
|
||||
{
|
||||
href: '/settings/export',
|
||||
text: intl.formatMessage(messages.importExport),
|
||||
},
|
||||
);
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
arr.push(null, {
|
||||
href: '/admin/reports',
|
||||
text: intl.formatMessage(messages.moderation),
|
||||
});
|
||||
}
|
||||
|
||||
if (canViewAdminDashboard(permissions)) {
|
||||
arr.push({
|
||||
href: '/admin/dashboard',
|
||||
text: intl.formatMessage(messages.administration),
|
||||
});
|
||||
}
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
arr.push(null, {
|
||||
text: intl.formatMessage(messages.logout),
|
||||
action: handleLogoutClick,
|
||||
});
|
||||
|
||||
return arr;
|
||||
}, [intl, dispatch, permissions, emojiReactionMenu]);
|
||||
|
||||
return (
|
||||
<Dropdown items={menu}>
|
||||
<button className='column-link column-link--transparent'>
|
||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||
|
||||
<FormattedMessage id='navigation_bar.more' defaultMessage='More' />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,204 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { NavLink, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { toggleNavigation } from 'mastodon/actions/navigation';
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
||||
notifications: {
|
||||
id: 'tabs_bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
menu: { id: 'tabs_bar.menu', defaultMessage: 'Menu' },
|
||||
});
|
||||
|
||||
const IconLabelButton: React.FC<{
|
||||
to: string;
|
||||
icon?: React.ReactNode;
|
||||
activeIcon?: React.ReactNode;
|
||||
title: string;
|
||||
}> = ({ to, icon, activeIcon, title }) => {
|
||||
const match = useRouteMatch(to);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
className='ui__navigation-bar__item'
|
||||
activeClassName='active'
|
||||
to={to}
|
||||
aria-label={title}
|
||||
>
|
||||
{match && activeIcon ? activeIcon : icon}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationsButton = () => {
|
||||
const count = useAppSelector(selectUnreadNotificationGroupsCount);
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<IconLabelButton
|
||||
to='/notifications'
|
||||
icon={
|
||||
<IconWithBadge
|
||||
id='bell'
|
||||
icon={NotificationsIcon}
|
||||
count={count}
|
||||
className=''
|
||||
/>
|
||||
}
|
||||
activeIcon={
|
||||
<IconWithBadge
|
||||
id='bell'
|
||||
icon={NotificationsActiveIcon}
|
||||
count={count}
|
||||
className=''
|
||||
/>
|
||||
}
|
||||
title={intl.formatMessage(messages.notifications)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginOrSignUp: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) as
|
||||
| string
|
||||
| null) ?? '/auth/sign_up',
|
||||
);
|
||||
|
||||
const openClosedRegistrationsModal = useCallback(() => {
|
||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchServer());
|
||||
}, [dispatch]);
|
||||
|
||||
if (sso_redirect) {
|
||||
return (
|
||||
<div className='ui__navigation-bar__sign-up'>
|
||||
<a
|
||||
href={sso_redirect}
|
||||
data-method='post'
|
||||
className='button button--block button-tertiary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.sso_redirect'
|
||||
defaultMessage='Login or Register'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let signupButton;
|
||||
|
||||
if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='button'>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='button' onClick={openClosedRegistrationsModal}>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.create_account'
|
||||
defaultMessage='Create account'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='ui__navigation-bar__sign-up'>
|
||||
{signupButton}
|
||||
<a href='/auth/sign_in' className='button button-tertiary'>
|
||||
<FormattedMessage
|
||||
id='sign_in_banner.sign_in'
|
||||
defaultMessage='Login'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const NavigationBar: React.FC = () => {
|
||||
const { signedIn } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
const open = useAppSelector((state) => state.navigation.open);
|
||||
const intl = useIntl();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(toggleNavigation());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='ui__navigation-bar'>
|
||||
{!signedIn && <LoginOrSignUp />}
|
||||
|
||||
<div
|
||||
className={classNames('ui__navigation-bar__items', {
|
||||
active: signedIn,
|
||||
})}
|
||||
>
|
||||
{signedIn && (
|
||||
<>
|
||||
<IconLabelButton
|
||||
title={intl.formatMessage(messages.home)}
|
||||
to='/home'
|
||||
icon={<Icon id='' icon={HomeIcon} />}
|
||||
activeIcon={<Icon id='' icon={HomeActiveIcon} />}
|
||||
/>
|
||||
<IconLabelButton
|
||||
title={intl.formatMessage(messages.search)}
|
||||
to='/explore'
|
||||
icon={<Icon id='' icon={SearchIcon} />}
|
||||
/>
|
||||
<IconLabelButton
|
||||
title={intl.formatMessage(messages.publish)}
|
||||
to='/publish'
|
||||
icon={<Icon id='' icon={AddIcon} />}
|
||||
/>
|
||||
<NotificationsButton />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={classNames('ui__navigation-bar__item', { active: open })}
|
||||
onClick={handleClick}
|
||||
aria-label={intl.formatMessage(messages.menu)}
|
||||
>
|
||||
<Icon id='' icon={MenuIcon} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,244 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { WordmarkLogo } from 'mastodon/components/logo';
|
||||
import { NavigationPortal } from 'mastodon/components/navigation_portal';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { enableDtlMenu, timelinePreview, trendsEnabled, dtlTag, enableLocalTimeline, isHideItem } from 'mastodon/initial_state';
|
||||
import { transientSingleColumn } from 'mastodon/is_mobile';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
|
||||
import ColumnLink from './column_link';
|
||||
import DisabledAccountBanner from './disabled_account_banner';
|
||||
import { ListPanel } from './list_panel';
|
||||
import SignInBanner from './sign_in_banner';
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
explore: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
local: { id: 'column.local', defaultMessage: 'Local' },
|
||||
deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' },
|
||||
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
|
||||
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
|
||||
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
|
||||
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
|
||||
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
});
|
||||
|
||||
const NotificationsLink = () => {
|
||||
|
||||
const count = useSelector(selectUnreadNotificationGroupsCount);
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
key='notifications'
|
||||
transparent
|
||||
to='/notifications'
|
||||
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />}
|
||||
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />}
|
||||
text={intl.formatMessage(messages.notifications)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowRequestsLink = () => {
|
||||
const count = useSelector(state => state.getIn(['user_lists', 'follow_requests', 'items'])?.size ?? 0);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFollowRequests());
|
||||
}, [dispatch]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/follow_requests'
|
||||
icon={<IconWithBadge id='user-plus' icon={PersonAddIcon} count={count} className='column-link__icon' />}
|
||||
activeIcon={<IconWithBadge id='user-plus' icon={PersonAddActiveIcon} count={count} className='column-link__icon' />}
|
||||
text={intl.formatMessage(messages.followRequests)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
class NavigationPanel extends Component {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
isFirehoseActive = (match, location) => {
|
||||
return (match || location.pathname.startsWith('/public')) && !location.pathname.endsWith('/fixed');
|
||||
};
|
||||
|
||||
isAntennasActive = (match, location) => {
|
||||
return (match || location.pathname.startsWith('/antennas'));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { signedIn, disabledAccountId, permissions } = this.props.identity;
|
||||
|
||||
const explorer = (trendsEnabled ? (
|
||||
<ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} />
|
||||
) : (
|
||||
<ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} />
|
||||
));
|
||||
|
||||
let banner = undefined;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
|
||||
</div>
|
||||
|
||||
{banner &&
|
||||
<div className='navigation-panel__banner'>
|
||||
{banner}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
||||
<NotificationsLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && enableLocalTimeline && (
|
||||
<ColumnLink transparent to='/public/local/fixed' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.local)} />
|
||||
)}
|
||||
|
||||
{signedIn && enableDtlMenu && dtlTag && (
|
||||
<ColumnLink transparent to={`/tags/${dtlTag}`} icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.deepLocal)} />
|
||||
)}
|
||||
|
||||
{!signedIn && explorer}
|
||||
|
||||
{signedIn && (
|
||||
<ColumnLink transparent to='/public' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
|
||||
{(!signedIn && timelinePreview) && (
|
||||
<ColumnLink transparent to={enableLocalTimeline ? '/public/local' : '/public'} isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ListPanel />
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
|
||||
<ColumnLink transparent to='/antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} isActive={this.isAntennasActive} />
|
||||
<ColumnLink transparent to='/circles' icon='user-circle' iconComponent={CirclesIcon} text={intl.formatMessage(messages.circles)} />
|
||||
<FollowRequestsLink />
|
||||
<ColumnLink transparent to='/conversations' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{signedIn && explorer}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/bookmark_categories' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
|
||||
{ !isHideItem('favourite_menu') && <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} /> }
|
||||
<hr />
|
||||
|
||||
<ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />
|
||||
|
||||
{canManageReports(permissions) && <ColumnLink transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
||||
{canViewAdminDashboard(permissions) && <ColumnLink transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<hr />
|
||||
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<NavigationPortal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(withIdentity(NavigationPanel));
|
|
@ -0,0 +1,515 @@
|
|||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { openNavigation, closeNavigation } from 'mastodon/actions/navigation';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { WordmarkLogo } from 'mastodon/components/logo';
|
||||
import { NavigationPortal } from 'mastodon/components/navigation_portal';
|
||||
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { me, enableDtlMenu, timelinePreview, trendsEnabled, dtlTag, enableLocalTimeline } from 'mastodon/initial_state';
|
||||
import { transientSingleColumn } from 'mastodon/is_mobile';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { ColumnLink } from './column_link';
|
||||
import DisabledAccountBanner from './disabled_account_banner';
|
||||
import { ListPanel } from './list_panel';
|
||||
import { MoreLink } from './more_link';
|
||||
import SignInBanner from './sign_in_banner';
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: {
|
||||
id: 'tabs_bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
explore: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
followsAndFollowers: {
|
||||
id: 'navigation_bar.follows_and_followers',
|
||||
defaultMessage: 'Follows and followers',
|
||||
},
|
||||
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
advancedInterface: {
|
||||
id: 'navigation_bar.advanced_interface',
|
||||
defaultMessage: 'Open in advanced web interface',
|
||||
},
|
||||
openedInClassicInterface: {
|
||||
id: 'navigation_bar.opened_in_classic_interface',
|
||||
defaultMessage:
|
||||
'Posts, accounts, and other specific pages are opened by default in the classic web interface.',
|
||||
},
|
||||
followRequests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
||||
local: { id: 'column.local', defaultMessage: 'Local' },
|
||||
deepLocal: { id: 'column.deep_local', defaultMessage: 'Deep' },
|
||||
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const NotificationsLink = () => {
|
||||
const count = useAppSelector(selectUnreadNotificationGroupsCount);
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
key='notifications'
|
||||
transparent
|
||||
to='/notifications'
|
||||
icon={
|
||||
<IconWithBadge
|
||||
id='bell'
|
||||
icon={NotificationsIcon}
|
||||
count={count}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
}
|
||||
activeIcon={
|
||||
<IconWithBadge
|
||||
id='bell'
|
||||
icon={NotificationsActiveIcon}
|
||||
count={count}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
}
|
||||
text={intl.formatMessage(messages.notifications)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowRequestsLink: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const count = useAppSelector(
|
||||
(state) =>
|
||||
(
|
||||
state.user_lists.getIn(['follow_requests', 'items']) as
|
||||
| ImmutableMap<string, unknown>
|
||||
| undefined
|
||||
)?.size ?? 0,
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFollowRequests());
|
||||
}, [dispatch]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/follow_requests'
|
||||
icon={
|
||||
<IconWithBadge
|
||||
id='user-plus'
|
||||
icon={PersonAddIcon}
|
||||
count={count}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
}
|
||||
activeIcon={
|
||||
<IconWithBadge
|
||||
id='user-plus'
|
||||
icon={PersonAddActiveIcon}
|
||||
count={count}
|
||||
className='column-link__icon'
|
||||
/>
|
||||
}
|
||||
text={intl.formatMessage(messages.followRequests)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchLink: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const showAsSearch = useBreakpoint('full');
|
||||
|
||||
if (!trendsEnabled || showAsSearch) {
|
||||
return (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={trendsEnabled ? '/explore' : '/search'}
|
||||
icon='search'
|
||||
iconComponent={SearchIcon}
|
||||
text={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={ExploreIcon}
|
||||
activeIconComponent={ExploreActiveIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileCard: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
}, [dispatch]);
|
||||
|
||||
if (!me) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Account id={me} minimal size={36} />
|
||||
<IconButton
|
||||
icon='sign-out'
|
||||
iconComponent={LogoutIcon}
|
||||
title={intl.formatMessage(messages.logout)}
|
||||
onClick={handleLogoutClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MENU_WIDTH = 284;
|
||||
|
||||
export const NavigationPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, disabledAccountId } = useIdentity();
|
||||
const open = useAppSelector((state) => state.navigation.open);
|
||||
const dispatch = useAppDispatch();
|
||||
const openable = useBreakpoint('openable');
|
||||
const location = useLocation();
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(closeNavigation());
|
||||
}, [dispatch, location]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
if (overlayRef.current && e.target === overlayRef.current) {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('keyup', handleDocumentKeyUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keyup', handleDocumentKeyUp);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const [{ x }, spring] = useSpring(
|
||||
() => ({
|
||||
x: open ? 0 : MENU_WIDTH,
|
||||
onRest: {
|
||||
x({ value }: { value: number }) {
|
||||
if (value === 0) {
|
||||
dispatch(openNavigation());
|
||||
} else if (value > 0) {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
[open],
|
||||
);
|
||||
|
||||
const bind = useDrag(
|
||||
({ last, offset: [ox], velocity: [vx], direction: [dx], cancel }) => {
|
||||
if (ox < -70) {
|
||||
cancel();
|
||||
}
|
||||
|
||||
if (last) {
|
||||
if (ox > MENU_WIDTH / 2 || (vx > 0.5 && dx > 0)) {
|
||||
void spring.start({ x: MENU_WIDTH });
|
||||
} else {
|
||||
void spring.start({ x: 0 });
|
||||
}
|
||||
} else {
|
||||
void spring.start({ x: ox, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [x.get(), 0],
|
||||
filterTaps: true,
|
||||
bounds: { left: 0 },
|
||||
rubberband: true,
|
||||
},
|
||||
);
|
||||
|
||||
const isFirehoseActive = useCallback(
|
||||
(match: unknown, location: { pathname: string }): boolean => {
|
||||
if (location.pathname.startsWith('/public/local/fixed')) return false;
|
||||
return !!match || location.pathname.startsWith('/public');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const previouslyFocusedElementRef = useRef<HTMLElement | null>();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const firstLink = document.querySelector<HTMLAnchorElement>(
|
||||
'.navigation-panel__menu .column-link',
|
||||
);
|
||||
previouslyFocusedElementRef.current =
|
||||
document.activeElement as HTMLElement;
|
||||
firstLink?.focus();
|
||||
} else {
|
||||
previouslyFocusedElementRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
let banner = undefined;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a
|
||||
href={`/deck${location.pathname}`}
|
||||
className='switch-to-advanced__toggle'
|
||||
>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefresh = useCallback(() => { window.location.reload(); }, []);
|
||||
|
||||
const showOverlay = openable && open;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational',
|
||||
{ 'columns-area__panels__pane--overlay': showOverlay },
|
||||
)}
|
||||
ref={overlayRef}
|
||||
>
|
||||
<animated.div
|
||||
className='columns-area__panels__pane__inner'
|
||||
{...bind()}
|
||||
style={openable ? { x } : undefined}
|
||||
>
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'>
|
||||
<WordmarkLogo />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ProfileCard />
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
{enableLocalTimeline && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local/fixed'
|
||||
icon='users'
|
||||
iconComponent={PeopleIcon}
|
||||
activeIconComponent={PeopleIcon}
|
||||
text={intl.formatMessage(messages.local)}
|
||||
/>
|
||||
)}
|
||||
{enableDtlMenu && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={`/tags/${dtlTag}`}
|
||||
icon='users'
|
||||
iconComponent={PeopleIcon}
|
||||
activeIconComponent={PeopleIcon}
|
||||
text={intl.formatMessage(messages.deepLocal)}
|
||||
/>
|
||||
)}
|
||||
<NotificationsLink />
|
||||
<FollowRequestsLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<SearchLink />
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={((signedIn || !enableLocalTimeline) ? '/public' : '/public/local')}
|
||||
isActive={isFirehoseActive}
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
text={intl.formatMessage(messages.firehose)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
{disabledAccountId ? (
|
||||
<DisabledAccountBanner />
|
||||
) : (
|
||||
<SignInBanner />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/circles'
|
||||
icon='user-circle'
|
||||
iconComponent={CirclesIcon}
|
||||
text={intl.formatMessage(messages.circles)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmark_categories'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
onClick={handleRefresh}
|
||||
icon='cog'
|
||||
iconComponent={RefreshIcon}
|
||||
text={intl.formatMessage(messages.refresh)}
|
||||
className='column-link column-link__transparent navigation-panel__refresh'
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
|
||||
<MoreLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<NavigationPortal />
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -4,8 +4,6 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { animated, config, useSpring } from '@react-spring/web';
|
||||
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
|
||||
interface UploadAreaProps {
|
||||
active?: boolean;
|
||||
onClose: () => void;
|
||||
|
@ -39,7 +37,6 @@ export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
|
|||
opacity: 1,
|
||||
},
|
||||
reverse: !active,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
const backgroundAnimStyles = useSpring({
|
||||
from: {
|
||||
|
@ -50,7 +47,6 @@ export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
|
|||
},
|
||||
reverse: !active,
|
||||
config: config.wobbly,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -17,19 +17,22 @@ const makeGetStatusIds = (pending = false) => createSelector([
|
|||
if (id === null || id === 'inline-follow-suggestions') return true;
|
||||
|
||||
const statusForId = statuses.get(id);
|
||||
let showStatus = true;
|
||||
|
||||
if (statusForId.get('account') === me) return true;
|
||||
|
||||
if (columnSettings.getIn(['shows', 'reblog']) === false) {
|
||||
showStatus = showStatus && statusForId.get('reblog') === null;
|
||||
if (columnSettings.getIn(['shows', 'reblog']) === false && statusForId.get('reblog') !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (columnSettings.getIn(['shows', 'reply']) === false) {
|
||||
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
|
||||
if (columnSettings.getIn(['shows', 'reply']) === false && statusForId.get('in_reply_to_id') !== null && statusForId.get('in_reply_to_account_id') !== me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return showStatus;
|
||||
if (columnSettings.getIn(['shows', 'quote']) === false && statusForId.get('quote') !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
53
app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx
Normal file
53
app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
const breakpoints = {
|
||||
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
|
||||
full: 1174, // Device width at which all 3 columns can be displayed
|
||||
};
|
||||
|
||||
type Breakpoint = 'openable' | 'full';
|
||||
|
||||
export const useBreakpoint = (breakpoint: Breakpoint) => {
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaWatcher = window.matchMedia(
|
||||
`(max-width: ${breakpoints[breakpoint]}px)`,
|
||||
);
|
||||
|
||||
setIsMatching(mediaWatcher.matches);
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setIsMatching(e.matches);
|
||||
};
|
||||
|
||||
mediaWatcher.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaWatcher.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [breakpoint, setIsMatching]);
|
||||
|
||||
return isMatching;
|
||||
};
|
||||
|
||||
interface WithBreakpointType {
|
||||
matchesBreakpoint: boolean;
|
||||
}
|
||||
|
||||
export function withBreakpoint<P>(
|
||||
Component: React.ComponentType<P & WithBreakpointType>,
|
||||
breakpoint: Breakpoint = 'full',
|
||||
) {
|
||||
const displayName = `withMobileLayout(${Component.displayName ?? Component.name})`;
|
||||
|
||||
const ComponentWithBreakpoint = (props: P) => {
|
||||
const matchesBreakpoint = useBreakpoint(breakpoint);
|
||||
|
||||
return <Component matchesBreakpoint={matchesBreakpoint} {...props} />;
|
||||
};
|
||||
|
||||
ComponentWithBreakpoint.displayName = displayName;
|
||||
|
||||
return ComponentWithBreakpoint;
|
||||
}
|
|
@ -29,7 +29,7 @@ import { expandHomeTimeline } from '../../actions/timelines';
|
|||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
|
||||
|
||||
import BundleColumnError from './components/bundle_column_error';
|
||||
import Header from './components/header';
|
||||
import { NavigationBar } from './components/navigation_bar';
|
||||
import { UploadArea } from './components/upload_area';
|
||||
import { HashtagMenuController } from './components/hashtag_menu_controller';
|
||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||
|
@ -652,12 +652,11 @@ class UI extends PureComponent {
|
|||
return (
|
||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
|
||||
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
|
||||
<Header />
|
||||
|
||||
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
|
||||
{children}
|
||||
</SwitchingColumnsArea>
|
||||
|
||||
<NavigationBar />
|
||||
{layout !== 'mobile' && <PictureInPicture />}
|
||||
<AlertsController />
|
||||
{!disableHoverCards && <HoverCardController />}
|
||||
|
|
|
@ -27,11 +27,7 @@ import {
|
|||
attachFullscreenListener,
|
||||
detachFullscreenListener,
|
||||
} from 'mastodon/features/ui/util/fullscreen';
|
||||
import {
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
reduceMotion,
|
||||
} from 'mastodon/initial_state';
|
||||
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||
import { playerSettings } from 'mastodon/settings';
|
||||
|
||||
import { HotkeyIndicator } from './components/hotkey_indicator';
|
||||
|
@ -260,7 +256,6 @@ export const Video: React.FC<{
|
|||
setMuted(videoRef.current.muted);
|
||||
void api.start({
|
||||
volume: `${videoRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -350,7 +345,6 @@ export const Video: React.FC<{
|
|||
videoRef.current.currentTime / videoRef.current.duration;
|
||||
void api.start({
|
||||
progress: isNaN(progress) ? '0%' : `${progress * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
config: config.stiff,
|
||||
});
|
||||
}
|
||||
|
@ -738,7 +732,6 @@ export const Video: React.FC<{
|
|||
if (lastTimeRange > -1) {
|
||||
void api.start({
|
||||
buffer: `${Math.ceil(videoRef.current.buffered.end(lastTimeRange) / videoRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
}, [api]);
|
||||
|
@ -753,7 +746,6 @@ export const Video: React.FC<{
|
|||
|
||||
void api.start({
|
||||
volume: `${videoRef.current.muted ? 0 : videoRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
persistVolume(videoRef.current.volume, videoRef.current.muted);
|
||||
|
|
62
app/javascript/mastodon/hooks/useAudioContext.ts
Normal file
62
app/javascript/mastodon/hooks/useAudioContext.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface AudioContextOptions {
|
||||
audioElementRef: React.MutableRefObject<HTMLAudioElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return an audio context instance for a given audio element [0].
|
||||
* Also returns an associated audio source, a gain node, and play and pause actions
|
||||
* which should be used instead of `audioElementRef.current.play/pause()`.
|
||||
*
|
||||
* [0] https://developer.mozilla.org/en-US/docs/Web/API/AudioContext
|
||||
*/
|
||||
|
||||
export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => {
|
||||
const audioContextRef = useRef<AudioContext>();
|
||||
const sourceRef = useRef<MediaElementAudioSourceNode>();
|
||||
const gainNodeRef = useRef<GainNode>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioElementRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = audioContextRef.current ?? new AudioContext();
|
||||
const source =
|
||||
sourceRef.current ??
|
||||
context.createMediaElementSource(audioElementRef.current);
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.connect(context.destination);
|
||||
source.connect(gainNode);
|
||||
|
||||
audioContextRef.current = context;
|
||||
gainNodeRef.current = gainNode;
|
||||
sourceRef.current = source;
|
||||
|
||||
return () => {
|
||||
if (context.state !== 'closed') {
|
||||
void context.close();
|
||||
}
|
||||
};
|
||||
}, [audioElementRef]);
|
||||
|
||||
const playAudio = useCallback(() => {
|
||||
void audioElementRef.current?.play();
|
||||
void audioContextRef.current?.resume();
|
||||
}, [audioElementRef]);
|
||||
|
||||
const pauseAudio = useCallback(() => {
|
||||
audioElementRef.current?.pause();
|
||||
void audioContextRef.current?.suspend();
|
||||
}, [audioElementRef]);
|
||||
|
||||
return {
|
||||
audioContextRef,
|
||||
sourceRef,
|
||||
gainNodeRef,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
};
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const normalizeFrequencies = (arr: Float32Array): number[] => {
|
||||
return new Array(...arr).map((value: number) => {
|
||||
|
@ -10,12 +10,17 @@ const normalizeFrequencies = (arr: Float32Array): number[] => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useAudioVisualizer = (
|
||||
ref: React.MutableRefObject<HTMLAudioElement | null>,
|
||||
numBands: number,
|
||||
) => {
|
||||
const audioContextRef = useRef<AudioContext>();
|
||||
const sourceRef = useRef<MediaElementAudioSourceNode>();
|
||||
interface AudioVisualiserOptions {
|
||||
audioContextRef: React.MutableRefObject<AudioContext | undefined>;
|
||||
sourceRef: React.MutableRefObject<MediaElementAudioSourceNode | undefined>;
|
||||
numBands: number;
|
||||
}
|
||||
|
||||
export const useAudioVisualizer = ({
|
||||
audioContextRef,
|
||||
sourceRef,
|
||||
numBands,
|
||||
}: AudioVisualiserOptions) => {
|
||||
const analyzerRef = useRef<AnalyserNode>();
|
||||
|
||||
const [frequencyBands, setFrequencyBands] = useState<number[]>(
|
||||
|
@ -23,47 +28,31 @@ export const useAudioVisualizer = (
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
if (audioContextRef.current) {
|
||||
analyzerRef.current = audioContextRef.current.createAnalyser();
|
||||
analyzerRef.current.smoothingTimeConstant = 0.6;
|
||||
analyzerRef.current.fftSize = 2048;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [audioContextRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
audioContextRef.current &&
|
||||
analyzerRef.current &&
|
||||
!sourceRef.current &&
|
||||
ref.current
|
||||
) {
|
||||
sourceRef.current = audioContextRef.current.createMediaElementSource(
|
||||
ref.current,
|
||||
);
|
||||
if (analyzerRef.current && sourceRef.current) {
|
||||
sourceRef.current.connect(analyzerRef.current);
|
||||
sourceRef.current.connect(audioContextRef.current.destination);
|
||||
}
|
||||
const currentSource = sourceRef.current;
|
||||
|
||||
return () => {
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
if (currentSource && analyzerRef.current) {
|
||||
currentSource.disconnect(analyzerRef.current);
|
||||
}
|
||||
};
|
||||
}, [ref]);
|
||||
}, [audioContextRef, sourceRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = sourceRef.current;
|
||||
const analyzer = analyzerRef.current;
|
||||
const context = audioContextRef.current;
|
||||
|
||||
if (!source || !analyzer || !context) {
|
||||
if (!analyzer || !context) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -94,19 +83,7 @@ export const useAudioVisualizer = (
|
|||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [numBands]);
|
||||
}, [numBands, audioContextRef]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.resume();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const suspend = useCallback(() => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.suspend();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [resume, suspend, frequencyBands] as const;
|
||||
return frequencyBands;
|
||||
};
|
||||
|
|
13
app/javascript/mastodon/hooks/useLayout.ts
Normal file
13
app/javascript/mastodon/hooks/useLayout.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { LayoutType } from '../is_mobile';
|
||||
import { useAppSelector } from '../store';
|
||||
|
||||
export const useLayout = () => {
|
||||
const layout = useAppSelector(
|
||||
(state) => state.meta.get('layout') as LayoutType,
|
||||
);
|
||||
|
||||
return {
|
||||
singleColumn: layout === 'single-column' || layout === 'mobile',
|
||||
layout,
|
||||
};
|
||||
};
|
16
app/javascript/mastodon/hooks/usePrevious.ts
Normal file
16
app/javascript/mastodon/hooks/usePrevious.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Returns the previous state of the passed in value.
|
||||
* On first render, undefined is returned.
|
||||
*/
|
||||
|
||||
export function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
|
@ -301,6 +301,7 @@
|
|||
"hashtag.follow": "Heuliañ ar ger-klik",
|
||||
"hashtag.unfollow": "Paouez heuliañ an hashtag",
|
||||
"hashtags.and_other": "…{count, plural, one {hag # all} other {ha # all}}",
|
||||
"home.column_settings.show_quotes": "Diskouez an arroudennoù",
|
||||
"home.column_settings.show_reblogs": "Diskouez ar skignadennoù",
|
||||
"home.column_settings.show_replies": "Diskouez ar respontoù",
|
||||
"home.hide_announcements": "Kuzhat ar c'hemennoù",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Vegeu més publicacions a {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Es poden haver perdut respostes d'altres servidors.",
|
||||
"hints.threads.see_more": "Vegeu més respostes a {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar les cites",
|
||||
"home.column_settings.show_reblogs": "Mostra els impulsos",
|
||||
"home.column_settings.show_replies": "Mostra les respostes",
|
||||
"home.hide_announcements": "Amaga els anuncis",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Zobrazit další příspěvky na {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Odpovědi z jiných serverů mohou chybět.",
|
||||
"hints.threads.see_more": "Zobrazit další odpovědi na {domain}",
|
||||
"home.column_settings.show_quotes": "Zobrazit citace",
|
||||
"home.column_settings.show_reblogs": "Zobrazit boosty",
|
||||
"home.column_settings.show_replies": "Zobrazit odpovědi",
|
||||
"home.hide_announcements": "Skrýt oznámení",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Gweinyddion a gyfyngir",
|
||||
"about.blocks": "Gweinyddion wedi'u cymedroli",
|
||||
"about.contact": "Cysylltwch â:",
|
||||
"about.default_locale": "Rhagosodedig",
|
||||
"about.disclaimer": "Mae Mastodon yn feddalwedd cod agored rhydd ac o dan hawlfraint Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Dyw'r rheswm ddim ar gael",
|
||||
"about.domain_blocks.preamble": "Fel rheol, mae Mastodon yn caniatáu i chi weld cynnwys gan unrhyw weinyddwr arall yn y ffedysawd a rhyngweithio â hi. Dyma'r eithriadau a wnaed ar y gweinydd penodol hwn.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Cyfyngedig",
|
||||
"about.domain_blocks.suspended.explanation": "Fydd data o'r gweinydd hwn ddim yn cael ei brosesu, ei gadw na'i gyfnewid, gan wneud unrhyw ryngweithio neu gyfathrebu gyda defnyddwyr o'r gweinydd hwn yn amhosibl.",
|
||||
"about.domain_blocks.suspended.title": "Wedi'i atal",
|
||||
"about.language_label": "Iaith",
|
||||
"about.not_available": "Dyw'r wybodaeth yma heb ei wneud ar gael ar y gweinydd hwn.",
|
||||
"about.powered_by": "Cyfrwng cymdeithasol datganoledig wedi ei yrru gan {mastodon}",
|
||||
"about.rules": "Rheolau'r gweinydd",
|
||||
|
@ -40,7 +42,7 @@
|
|||
"account.follow_back": "Dilyn nôl",
|
||||
"account.followers": "Dilynwyr",
|
||||
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwyr}}",
|
||||
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwr}}",
|
||||
"account.followers_you_know_counter": "{counter} rydych chi'n adnabod",
|
||||
"account.following": "Yn dilyn",
|
||||
"account.following_counter": "{count, plural, one {Yn dilyn {counter}} other {Yn dilyn {counter} arall}}",
|
||||
|
@ -127,7 +129,7 @@
|
|||
"annual_report.summary.thanks": "Diolch am fod yn rhan o Mastodon!",
|
||||
"attachments_list.unprocessed": "(heb eu prosesu)",
|
||||
"audio.hide": "Cuddio sain",
|
||||
"block_modal.remote_users_caveat": "Byddwn yn gofyn i'r gweinydd {domain} barchu eich penderfyniad. Fodd bynnag, nid yw cydymffurfiad wedi'i warantu gan y gall rhai gweinyddwyr drin rhwystro mewn ffyrdd gwahanol. Mae'n bosibl y bydd postiadau cyhoeddus yn dal i fod yn weladwy i ddefnyddwyr nad ydynt wedi mewngofnodi.",
|
||||
"block_modal.remote_users_caveat": "Byddwn yn gofyn i'r gweinydd {domain} barchu eich penderfyniad. Fodd bynnag, nid yw cydymffurfiad wedi'i warantu gan y gall rhai gweinyddwyr drin rhwystrau mewn ffyrdd gwahanol. Mae'n bosibl y bydd postiadau cyhoeddus yn dal i fod yn weladwy i ddefnyddwyr nad ydynt wedi mewngofnodi.",
|
||||
"block_modal.show_less": "Dangos llai",
|
||||
"block_modal.show_more": "Dangos rhagor",
|
||||
"block_modal.they_cant_mention": "Dydyn nhw ddim yn gallu eich crybwyll na'ch dilyn.",
|
||||
|
@ -236,8 +238,8 @@
|
|||
"confirmations.missing_alt_text.title": "Ychwanegu testun amgen?",
|
||||
"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 ac ailddraftio'r postiad?",
|
||||
"confirmations.redraft.message": "Ydych chi wir eisiau'r dileu'r postiad hwn a'i ail lunio? Bydd ffefrynnau a hybiau'n cael eu colli, a bydd atebion i'r postiad gwreiddiol yn mynd yn amddifad.",
|
||||
"confirmations.redraft.title": "Dileu ac ail lunio'r postiad?",
|
||||
"confirmations.remove_from_followers.confirm": "Dileu dilynwr",
|
||||
"confirmations.remove_from_followers.message": "Bydd {name} yn rhoi'r gorau i'ch dilyn. A ydych yn siŵr eich bod am fwrw ymlaen?",
|
||||
"confirmations.remove_from_followers.title": "Tynnu dilynwr?",
|
||||
|
@ -286,8 +288,8 @@
|
|||
"domain_pill.their_username": "Eu dynodwr unigryw ar eu gweinydd. Mae'n bosibl dod o hyd i ddefnyddwyr gyda'r un enw defnyddiwr ar wahanol weinyddion.",
|
||||
"domain_pill.username": "Enw Defnyddiwr",
|
||||
"domain_pill.whats_in_a_handle": "Beth sydd mewn handlen?",
|
||||
"domain_pill.who_they_are": "Gan fod handlen yn dweud pwy yw rhywun a ble maen nhw, gallwch chi ryngweithio â phobl ar draws gwe gymdeithasol <button>llwyfannau wedi'u pweru gan ActivityPub</button> .",
|
||||
"domain_pill.who_you_are": "Oherwydd bod eich handlen yn dweud pwy ydych chi a ble rydych chi, gall pobl ryngweithio â chi ar draws gwe gymdeithasol <button>llwyfannau wedi'u pweru gan ActivityPub</button> .",
|
||||
"domain_pill.who_they_are": "Gan fod handlen yn dweud pwy yw rhywun a ble maen nhw, gallwch chi ryngweithio â phobl ar draws gwe gymdeithasol <button>llwyfannau wedi'u pweru gan ActivityPub</button>.",
|
||||
"domain_pill.who_you_are": "Oherwydd bod eich handlen yn dweud pwy ydych chi a ble rydych chi, gall pobl ryngweithio â chi ar draws gwe gymdeithasol <button>llwyfannau wedi'u pweru gan ActivityPub</button>.",
|
||||
"domain_pill.your_handle": "Eich handlen:",
|
||||
"domain_pill.your_server": "Eich cartref digidol, lle mae'ch holl bostiadau'n byw. Ddim yn hoffi'r un hon? Trosglwyddwch weinyddion ar unrhyw adeg a dewch â'ch dilynwyr hefyd.",
|
||||
"domain_pill.your_username": "Eich dynodwr unigryw ar y gweinydd hwn. Mae'n bosibl dod o hyd i ddefnyddwyr gyda'r un enw defnyddiwr ar wahanol weinyddion.",
|
||||
|
@ -314,26 +316,26 @@
|
|||
"empty_column.account_hides_collections": "Mae'r defnyddiwr wedi dewis i beidio rhannu'r wybodaeth yma",
|
||||
"empty_column.account_suspended": "Cyfrif wedi'i atal",
|
||||
"empty_column.account_timeline": "Dim postiadau yma!",
|
||||
"empty_column.account_unavailable": "Nid yw'r proffil ar gael",
|
||||
"empty_column.account_unavailable": "Dyw'r proffil ddim ar gael",
|
||||
"empty_column.blocks": "Dydych chi heb rwystro unrhyw ddefnyddwyr eto.",
|
||||
"empty_column.bookmarked_statuses": "Does gennych chi ddim unrhyw bostiad wedi'u cadw fel nod tudalen eto. Pan fyddwch yn gosod nod tudalen i un, mi fydd yn ymddangos yma.",
|
||||
"empty_column.community": "Mae'r ffrwd lleol yn wag. Beth am ysgrifennu rhywbeth cyhoeddus!",
|
||||
"empty_column.direct": "Does gennych chi unrhyw grybwylliadau preifat eto. Pan fyddwch chi'n anfon neu'n derbyn un, bydd yn ymddangos yma.",
|
||||
"empty_column.domain_blocks": "Nid oes unrhyw barthau wedi'u blocio eto.",
|
||||
"empty_column.domain_blocks": "Does dim parthau wedi'u rhwystro eto.",
|
||||
"empty_column.explore_statuses": "Does dim pynciau llosg ar hyn o bryd. Dewch nôl nes ymlaen!",
|
||||
"empty_column.favourited_statuses": "Rydych chi heb ffafrio unrhyw bostiadau eto. Pan byddwch chi'n ffafrio un, bydd yn ymddangos yma.",
|
||||
"empty_column.favourites": "Nid oes unrhyw un wedi ffafrio'r postiad hwn eto. Pan fydd rhywun yn gwneud hynny, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.follow_requests": "Nid oes gennych unrhyw geisiadau dilyn eto. Pan fyddwch yn derbyn un, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.followed_tags": "Nid ydych wedi dilyn unrhyw hashnodau eto. Pan fyddwch chi'n gwneud hynny, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.hashtag": "Nid oes dim ar yr hashnod hwn eto.",
|
||||
"empty_column.home": "Mae eich ffrwd gartref yn wag! Dilynwch fwy o bobl i'w llenwi.",
|
||||
"empty_column.favourites": "Does neb wedi ffafrio'r postiad hwn eto. Pan fydd rhywun yn gwneud hynny, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.follow_requests": "Does gennych chi ddim ceisiadau dilyn eto. Pan fyddwch yn derbyn un, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.followed_tags": "Dydych chi heb ddilyn unrhyw hashnodau eto. Pan fyddwch chi'n gwneud hynny, byddan nhw'n ymddangos yma.",
|
||||
"empty_column.hashtag": "Does dim ar yr hashnod hwn eto.",
|
||||
"empty_column.home": "Mae eich ffrwd gartref yn wag! Dilynwch ragor o bobl i'w llenwi.",
|
||||
"empty_column.list": "Does dim yn y rhestr yma eto. Pan fydd aelodau'r rhestr yn cyhoeddi postiad newydd, mi fydd yn ymddangos yma.",
|
||||
"empty_column.mutes": "Nid ydych wedi tewi unrhyw ddefnyddwyr eto.",
|
||||
"empty_column.mutes": "Dydych chi heb dewi unrhyw ddefnyddwyr eto.",
|
||||
"empty_column.notification_requests": "Dim i boeni amdano! Does dim byd yma. Pan fyddwch yn derbyn hysbysiadau newydd, byddan nhw'n ymddangos yma yn ôl eich gosodiadau.",
|
||||
"empty_column.notifications": "Nid oes gennych unrhyw hysbysiadau eto. Rhyngweithiwch ag eraill i ddechrau'r sgwrs.",
|
||||
"empty_column.notifications": "Does gennych chi ddim hysbysiadau eto. Pan fyddwch chi'n rhyngweithio ag eraill, byddwch yn ei weld yma.",
|
||||
"empty_column.public": "Does dim byd yma! Ysgrifennwch rywbeth cyhoeddus, neu dilynwch ddefnyddwyr o weinyddion eraill i'w lanw",
|
||||
"error.unexpected_crash.explanation": "Oherwydd gwall yn ein cod neu oherwydd problem cysondeb porwr, nid oedd y dudalen hon gallu cael ei dangos yn gywir.",
|
||||
"error.unexpected_crash.explanation_addons": "Nid oes modd dangos y dudalen hon yn gywir. Mae'r gwall hwn yn debygol o gael ei achosi gan ategyn porwr neu offer cyfieithu awtomatig.",
|
||||
"error.unexpected_crash.explanation_addons": "Does dim modd dangos y dudalen hon yn gywir. Mae'r gwall hwn yn debygol o gael ei achosi gan ategyn porwr neu offer cyfieithu awtomatig.",
|
||||
"error.unexpected_crash.next_steps": "Ceisiwch ail-lwytho'r dudalen. Os nad yw hyn yn eich helpu, efallai gallwch ddefnyddio Mastodon trwy borwr neu ap brodorol gwahanol.",
|
||||
"error.unexpected_crash.next_steps_addons": "Ceisiwch eu hanalluogi ac adnewyddu'r dudalen. Os nad yw hynny'n helpu, efallai y byddwch yn dal i allu defnyddio Mastodon trwy borwr neu ap cynhenid arall.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Copïo'r olrhain stac i'r clipfwrdd",
|
||||
|
@ -343,11 +345,12 @@
|
|||
"explore.trending_links": "Newyddion",
|
||||
"explore.trending_statuses": "Postiadau",
|
||||
"explore.trending_tags": "Hashnodau",
|
||||
"featured_carousel.header": "{count, plural, one {Postiad wedi'i binio} other {Postiadau wedi'u pinio}}",
|
||||
"featured_carousel.next": "Nesaf",
|
||||
"featured_carousel.post": "Postiad",
|
||||
"featured_carousel.previous": "Blaenorol",
|
||||
"featured_carousel.slide": "{index} o {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Nid yw'r categori hidlo hwn yn berthnasol i'r cyd-destun yr ydych wedi cyrchu'r postiad hwn ynddo. Os ydych chi am i'r postiad gael ei hidlo yn y cyd-destun hwn hefyd, bydd yn rhaid i chi olygu'r hidlydd.",
|
||||
"filter_modal.added.context_mismatch_explanation": "Dyw'r categori hidlo hwn ddim yn berthnasol i'r cyd-destun yr ydych wedi cyrchu'r postiad hwn ynddo. Os ydych chi am i'r postiad gael ei hidlo yn y cyd-destun hwn hefyd, bydd yn rhaid i chi olygu'r hidlydd.",
|
||||
"filter_modal.added.context_mismatch_title": "Diffyg cyfatebiaeth cyd-destun!",
|
||||
"filter_modal.added.expired_explanation": "Mae'r categori hidlydd hwn wedi dod i ben, bydd angen i chi newid y dyddiad dod i ben er mwyn iddo fod yn berthnasol.",
|
||||
"filter_modal.added.expired_title": "Hidlydd wedi dod i ben!",
|
||||
|
@ -367,11 +370,11 @@
|
|||
"filtered_notifications_banner.pending_requests": "Oddi wrth {count, plural, =0 {no one} one {un person} two {# berson} few {# pherson} other {# person}} efallai eich bod yn eu hadnabod",
|
||||
"filtered_notifications_banner.title": "Hysbysiadau wedi'u hidlo",
|
||||
"firehose.all": "Popeth",
|
||||
"firehose.local": "Gweinydd hwn",
|
||||
"firehose.local": "Y gweinydd hwn",
|
||||
"firehose.remote": "Gweinyddion eraill",
|
||||
"follow_request.authorize": "Awdurdodi",
|
||||
"follow_request.reject": "Gwrthod",
|
||||
"follow_requests.unlocked_explanation": "Er nid yw eich cyfrif wedi'i gloi, roedd y staff {domain} yn meddwl efallai hoffech adolygu ceisiadau dilyn o'r cyfrifau rhain wrth law.",
|
||||
"follow_requests.unlocked_explanation": "Er nad yw eich cyfrif wedi'i gloi, roedd y staff {domain} yn meddwl efallai hoffech adolygu ceisiadau dilyn o'r cyfrifau rhain wrth law.",
|
||||
"follow_suggestions.curated_suggestion": "Dewis staff",
|
||||
"follow_suggestions.dismiss": "Peidio â dangos hwn eto",
|
||||
"follow_suggestions.featured_longer": "Wedi'i ddewis â llaw gan dîm {domain}",
|
||||
|
@ -384,32 +387,32 @@
|
|||
"follow_suggestions.personalized_suggestion": "Awgrym personol",
|
||||
"follow_suggestions.popular_suggestion": "Awgrym poblogaidd",
|
||||
"follow_suggestions.popular_suggestion_longer": "Yn boblogaidd ar {domain}",
|
||||
"follow_suggestions.similar_to_recently_followed_longer": "Yn debyg i broffiliau y gwnaethoch chi eu dilyn yn ddiweddar",
|
||||
"follow_suggestions.similar_to_recently_followed_longer": "Yn debyg i broffiliau rydych wedi'u dilyn yn ddiweddar",
|
||||
"follow_suggestions.view_all": "Gweld y cyfan",
|
||||
"follow_suggestions.who_to_follow": "Pwy i ddilyn",
|
||||
"followed_tags": "Hashnodau rydych yn eu dilyn",
|
||||
"footer.about": "Ynghylch",
|
||||
"footer.directory": "Cyfeiriadur proffiliau",
|
||||
"footer.get_app": "Lawrlwytho'r ap",
|
||||
"footer.get_app": "Llwytho'r ap i lawr",
|
||||
"footer.keyboard_shortcuts": "Bysellau brys",
|
||||
"footer.privacy_policy": "Polisi preifatrwydd",
|
||||
"footer.source_code": "Gweld y cod ffynhonnell",
|
||||
"footer.status": "Statws",
|
||||
"footer.terms_of_service": "Telerau gwasanaeth",
|
||||
"generic.saved": "Wedi'i Gadw",
|
||||
"getting_started.heading": "Dechrau",
|
||||
"getting_started.heading": "Dechrau arni",
|
||||
"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}",
|
||||
"hashtag.column_settings.select.no_options_message": "Dim awgrymiadau i'w weld",
|
||||
"hashtag.column_settings.select.no_options_message": "Dim awgrymiadau i'w gweld",
|
||||
"hashtag.column_settings.select.placeholder": "Mewnbynnu hashnodau…",
|
||||
"hashtag.column_settings.tag_mode.all": "Pob un o'r rhain",
|
||||
"hashtag.column_settings.tag_mode.any": "Unrhyw un o'r rhain",
|
||||
"hashtag.column_settings.tag_mode.none": "Dim o'r rhain",
|
||||
"hashtag.column_settings.tag_toggle": "Include additional tags in this column",
|
||||
"hashtag.column_settings.tag_toggle": "Cynnwys tagiau ychwanegol ar gyfer y golofn hon",
|
||||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} cyfranogwr} other {{counter} cyfranogwr}}",
|
||||
"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",
|
||||
|
@ -434,7 +437,7 @@
|
|||
"home.pending_critical_update.link": "Gweld diweddariadau",
|
||||
"home.pending_critical_update.title": "Mae diweddariad diogelwch hanfodol ar gael!",
|
||||
"home.show_announcements": "Dangos cyhoeddiadau",
|
||||
"ignore_notifications_modal.disclaimer": "Ni all Mastodon hysbysu defnyddwyr eich bod wedi anwybyddu eu hysbysiadau. Ni fydd anwybyddu hysbysiadau yn atal y negeseuon eu hunain rhag cael eu hanfon.",
|
||||
"ignore_notifications_modal.disclaimer": "Dyw Mastodon ddim yn gallu hysbysu defnyddwyr eich bod wedi anwybyddu eu hysbysiadau. Bydd anwybyddu hysbysiadau ddim yn atal y negeseuon eu hunain rhag cael eu hanfon.",
|
||||
"ignore_notifications_modal.filter_instead": "Hidlo yn lle hynny",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Byddwch yn dal i allu derbyn, gwrthod neu adrodd ar ddefnyddwyr",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Mae hidlo yn helpu i osgoi dryswch posibl",
|
||||
|
@ -461,12 +464,12 @@
|
|||
"interaction_modal.title.reblog": "Hybu postiad {name}",
|
||||
"interaction_modal.title.reply": "Ymateb i bostiad {name}",
|
||||
"interaction_modal.title.vote": "Pleidleisiwch ym mhleidlais {name}",
|
||||
"interaction_modal.username_prompt": "E.e. {example}",
|
||||
"interaction_modal.username_prompt": "e.e. {example}",
|
||||
"intervals.full.days": "{number, plural, one {# diwrnod} two {# ddiwrnod} other {# diwrnod}}",
|
||||
"intervals.full.hours": "{number, plural, one {# awr} other {# o oriau}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# funud} other {# o funudau}}",
|
||||
"keyboard_shortcuts.back": "Llywio nôl",
|
||||
"keyboard_shortcuts.blocked": "Agor rhestr defnyddwyr a flociwyd",
|
||||
"intervals.full.hours": "{number, plural, one {# awr} other {# awr}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# funud} other {# munud}}",
|
||||
"keyboard_shortcuts.back": "Symud nôl",
|
||||
"keyboard_shortcuts.blocked": "Agor rhestr defnyddwyr sydd wedi'i rwystro",
|
||||
"keyboard_shortcuts.boost": "Hybu postiad",
|
||||
"keyboard_shortcuts.column": "Ffocysu colofn",
|
||||
"keyboard_shortcuts.compose": "Ffocysu ar ardal cyfansoddi testun",
|
||||
|
@ -480,7 +483,7 @@
|
|||
"keyboard_shortcuts.heading": "Bysellau brys",
|
||||
"keyboard_shortcuts.home": "Agor ffrwd gartref",
|
||||
"keyboard_shortcuts.hotkey": "Bysell boeth",
|
||||
"keyboard_shortcuts.legend": "Dangos y rhestr hon",
|
||||
"keyboard_shortcuts.legend": "Dangos yr allwedd hon",
|
||||
"keyboard_shortcuts.local": "Agor ffrwd lleol",
|
||||
"keyboard_shortcuts.mention": "Crybwyll yr awdur",
|
||||
"keyboard_shortcuts.muted": "Agor rhestr defnyddwyr rydych wedi'u tewi",
|
||||
|
@ -489,7 +492,7 @@
|
|||
"keyboard_shortcuts.open_media": "Agor cyfryngau",
|
||||
"keyboard_shortcuts.pinned": "Agor rhestr postiadau wedi'u pinio",
|
||||
"keyboard_shortcuts.profile": "Agor proffil yr awdur",
|
||||
"keyboard_shortcuts.reply": "Ymateb i bostiad",
|
||||
"keyboard_shortcuts.reply": "Ateb postiad",
|
||||
"keyboard_shortcuts.requests": "Agor rhestr ceisiadau dilyn",
|
||||
"keyboard_shortcuts.search": "Ffocysu ar y bar chwilio",
|
||||
"keyboard_shortcuts.spoilers": "Dangos/cuddio'r maes CW",
|
||||
|
@ -557,7 +560,7 @@
|
|||
"navigation_bar.compose": "Cyfansoddi post newydd",
|
||||
"navigation_bar.direct": "Crybwylliadau preifat",
|
||||
"navigation_bar.discover": "Darganfod",
|
||||
"navigation_bar.domain_blocks": "Parthau wedi'u blocio",
|
||||
"navigation_bar.domain_blocks": "Parthau wedi'u rhwystro",
|
||||
"navigation_bar.explore": "Darganfod",
|
||||
"navigation_bar.favourites": "Ffefrynnau",
|
||||
"navigation_bar.filters": "Geiriau wedi'u tewi",
|
||||
|
@ -609,7 +612,7 @@
|
|||
"notification.moderation_warning.action_silence": "Mae eich cyfrif wedi'i gyfyngu.",
|
||||
"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.poll": "Mae arolwg rydych wedi pleidleisio ynddo wedi dod i ben",
|
||||
"notification.reblog": "Hybodd {name} eich post",
|
||||
"notification.reblog.name_and_others_with_link": "Mae {name} a <a>{count, plural, one {# arall} other {# arall}}</a> wedi hybu eich postiad",
|
||||
"notification.relationships_severance_event": "Wedi colli cysylltiad â {name}",
|
||||
|
@ -652,7 +655,7 @@
|
|||
"notifications.column_settings.group": "Grŵp",
|
||||
"notifications.column_settings.mention": "Crybwylliadau:",
|
||||
"notifications.column_settings.poll": "Canlyniadau pleidlais:",
|
||||
"notifications.column_settings.push": "Hysbysiadau gwthiadwy",
|
||||
"notifications.column_settings.push": "Hysbysiadau gwthio",
|
||||
"notifications.column_settings.reblog": "Hybiau:",
|
||||
"notifications.column_settings.show": "Dangos yn y golofn",
|
||||
"notifications.column_settings.sound": "Chwarae sain",
|
||||
|
@ -665,25 +668,25 @@
|
|||
"notifications.filter.favourites": "Ffefrynnau",
|
||||
"notifications.filter.follows": "Yn dilyn",
|
||||
"notifications.filter.mentions": "Crybwylliadau",
|
||||
"notifications.filter.polls": "Canlyniadau polau",
|
||||
"notifications.filter.polls": "Canlyniadau pleidleisio",
|
||||
"notifications.filter.statuses": "Diweddariadau gan bobl rydych chi'n eu dilyn",
|
||||
"notifications.grant_permission": "Caniatáu.",
|
||||
"notifications.group": "{count} hysbysiad",
|
||||
"notifications.mark_as_read": "Marciwch bob hysbysiad wedi'i ddarllen",
|
||||
"notifications.permission_denied": "Nid oes hysbysiadau bwrdd gwaith ar gael oherwydd cais am ganiatâd porwr a wrthodwyd yn flaenorol",
|
||||
"notifications.permission_denied_alert": "Nid oes modd galluogi hysbysiadau bwrdd gwaith, gan fod caniatâd porwr wedi'i wrthod o'r blaen",
|
||||
"notifications.permission_required": "Nid oes hysbysiadau bwrdd gwaith ar gael oherwydd na roddwyd y caniatâd gofynnol.",
|
||||
"notifications.permission_denied": "Does dim hysbysiadau bwrdd gwaith ar gael oherwydd cais am ganiatâd porwr a wrthodwyd yn flaenorol",
|
||||
"notifications.permission_denied_alert": "Does dim modd galluogi hysbysiadau bwrdd gwaith, gan fod caniatâd porwr wedi'i wrthod o'r blaen",
|
||||
"notifications.permission_required": "Does dim hysbysiadau bwrdd gwaith ar gael oherwydd na roddwyd y caniatâd gofynnol.",
|
||||
"notifications.policy.accept": "Derbyn",
|
||||
"notifications.policy.accept_hint": "Dangos mewn hysbysiadau",
|
||||
"notifications.policy.drop": "Anwybyddu",
|
||||
"notifications.policy.drop_hint": "Anfon i'r gwagle, byth i'w gweld eto",
|
||||
"notifications.policy.filter": "Hidlo",
|
||||
"notifications.policy.filter_hint": "Anfon i flwch derbyn hysbysiadau wedi'u hidlo",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Cyfyngedig gan gymedrolwyr gweinydd",
|
||||
"notifications.policy.filter_limited_accounts_hint": "Cyfyngwyd gan gymedrolwyr gweinydd",
|
||||
"notifications.policy.filter_limited_accounts_title": "Cyfrifon wedi'u cymedroli",
|
||||
"notifications.policy.filter_new_accounts.hint": "Crëwyd o fewn {days, lluosog, un {yr un diwrnod} arall {y # diwrnod}} diwethaf",
|
||||
"notifications.policy.filter_new_accounts.hint": "Crëwyd o fewn {days, plural, one {yr un diwrnod} other {y # diwrnod}} diwethaf",
|
||||
"notifications.policy.filter_new_accounts_title": "Cyfrifon newydd",
|
||||
"notifications.policy.filter_not_followers_hint": "Gan gynnwys pobl sydd wedi bod yn eich dilyn am llai {days, plural, un {nag un diwrnod} arall {na # diwrnod}}",
|
||||
"notifications.policy.filter_not_followers_hint": "Gan gynnwys pobl sydd wedi bod yn eich dilyn am llai {days, plural, one {nag un diwrnod} other {na # diwrnod}}",
|
||||
"notifications.policy.filter_not_followers_title": "Pobl sydd ddim yn eich dilyn",
|
||||
"notifications.policy.filter_not_following_hint": "Hyd nes i chi eu cymeradwyo â llaw",
|
||||
"notifications.policy.filter_not_following_title": "Pobl nad ydych yn eu dilyn",
|
||||
|
@ -699,7 +702,7 @@
|
|||
"onboarding.follows.search": "Chwilio",
|
||||
"onboarding.follows.title": "Dilynwch bobl i gychwyn arni",
|
||||
"onboarding.profile.discoverable": "Gwnewch fy mhroffil yn un y gellir ei ddarganfod",
|
||||
"onboarding.profile.discoverable_hint": "Pan fyddwch yn optio i mewn i ddarganfodadwyedd ar Mastodon, gall eich postiadau ymddangos mewn canlyniadau chwilio a threndiau, ac efallai y bydd eich proffil yn cael ei awgrymu i bobl sydd â diddordebau tebyg i chi.",
|
||||
"onboarding.profile.discoverable_hint": "Pan fyddwch yn dewis ymuno â darganfod ar Mastodon, gall eich postiadau ymddangos mewn canlyniadau chwilio a threndiau, ac efallai y bydd eich proffil yn cael ei awgrymu i bobl sydd â diddordebau tebyg i chi.",
|
||||
"onboarding.profile.display_name": "Enw dangos",
|
||||
"onboarding.profile.display_name_hint": "Eich enw llawn neu'ch enw hwyl…",
|
||||
"onboarding.profile.note": "Bywgraffiad",
|
||||
|
@ -710,7 +713,7 @@
|
|||
"onboarding.profile.upload_header": "Llwytho pennyn proffil",
|
||||
"password_confirmation.exceeds_maxlength": "Mae'r cadarnhad cyfrinair yn fwy nag uchafswm hyd y cyfrinair",
|
||||
"password_confirmation.mismatching": "Nid yw'r cadarnhad cyfrinair yn cyfateb",
|
||||
"picture_in_picture.restore": "Rhowch ef yn ôl",
|
||||
"picture_in_picture.restore": "Rhowch e nôl",
|
||||
"poll.closed": "Ar gau",
|
||||
"poll.refresh": "Adnewyddu",
|
||||
"poll.reveal": "Gweld y canlyniadau",
|
||||
|
@ -724,9 +727,9 @@
|
|||
"privacy.change": "Addasu preifatrwdd y post",
|
||||
"privacy.direct.long": "Pawb sydd â sôn amdanyn nhw yn y postiad",
|
||||
"privacy.direct.short": "Crybwylliad preifat",
|
||||
"privacy.private.long": "Eich dilynwyr yn unig",
|
||||
"privacy.private.long": "Dim ond eich dilynwyr",
|
||||
"privacy.private.short": "Dilynwyr",
|
||||
"privacy.public.long": "Unrhyw ar ac oddi ar Mastodon",
|
||||
"privacy.public.long": "Unrhyw un ar ac oddi ar Mastodon",
|
||||
"privacy.public.short": "Cyhoeddus",
|
||||
"privacy.unlisted.additional": "Mae hwn yn ymddwyn yn union fel y cyhoeddus, ac eithrio na fydd y postiad yn ymddangos mewn ffrydiau byw neu hashnodau, archwilio, neu chwiliad Mastodon, hyd yn oed os ydych wedi eich cynnwys ar draws y cyfrif.",
|
||||
"privacy.unlisted.long": "Llai o ddathliadau algorithmig",
|
||||
|
@ -736,7 +739,7 @@
|
|||
"recommended": "Argymhellwyd",
|
||||
"refresh": "Adnewyddu",
|
||||
"regeneration_indicator.please_stand_by": "Arhoswch am dipyn.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Paratoi eich llif cartref…",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Yn paratoi eich ffrwd gartref…",
|
||||
"relative_time.days": "{number}d",
|
||||
"relative_time.full.days": "{number, plural, one {# diwrnod} other {# diwrnod}} yn ôl",
|
||||
"relative_time.full.hours": "{number, plural, one {# awr} other {# awr}} yn ôl",
|
||||
|
@ -749,16 +752,16 @@
|
|||
"relative_time.seconds": "{number} eiliad",
|
||||
"relative_time.today": "heddiw",
|
||||
"reply_indicator.attachments": "{count, plural, one {# atodiad} other {# atodiad}}",
|
||||
"reply_indicator.cancel": "Canslo",
|
||||
"reply_indicator.poll": "Arolwg",
|
||||
"report.block": "Blocio",
|
||||
"reply_indicator.cancel": "Diddymu",
|
||||
"reply_indicator.poll": "Pleidlais",
|
||||
"report.block": "Rhwystro",
|
||||
"report.block_explanation": "Ni welwch chi eu postiadau. Ni allan nhw weld eich postiadau na'ch dilyn. Byddan nhw'n gallu gweld eu bod nhw wedi'u rhwystro.",
|
||||
"report.categories.legal": "Cyfreithiol",
|
||||
"report.categories.other": "Arall",
|
||||
"report.categories.spam": "Sbam",
|
||||
"report.categories.violation": "Mae cynnwys yn torri un neu fwy o reolau'r gweinydd",
|
||||
"report.category.subtitle": "Dewiswch yr ateb gorau",
|
||||
"report.category.title": "Beth sy'n digwydd gyda'r {type} yma?",
|
||||
"report.category.title": "Beth sy'n digwydd gyda'r {type} yma",
|
||||
"report.category.title_account": "proffil",
|
||||
"report.category.title_status": "post",
|
||||
"report.close": "Iawn",
|
||||
|
@ -770,11 +773,11 @@
|
|||
"report.next": "Nesaf",
|
||||
"report.placeholder": "Sylwadau ychwanegol",
|
||||
"report.reasons.dislike": "Dydw i ddim yn ei hoffi",
|
||||
"report.reasons.dislike_description": "Nid yw'n rhywbeth yr ydych am ei weld",
|
||||
"report.reasons.dislike_description": "Dyw e ddim yn rhywbeth rydych am ei weld",
|
||||
"report.reasons.legal": "Mae'n anghyfreithlon",
|
||||
"report.reasons.legal_description": "Rydych chi'n credu ei fod yn torri cyfraith eich gwlad chi neu wlad y gweinydd",
|
||||
"report.reasons.other": "Mae'n rhywbeth arall",
|
||||
"report.reasons.other_description": "Nid yw'r mater yn ffitio i gategorïau eraill",
|
||||
"report.reasons.other_description": "Dyw'r mater ddim yn ffitio i gategorïau eraill",
|
||||
"report.reasons.spam": "Sbam yw e",
|
||||
"report.reasons.spam_description": "Dolenni maleisus, ymgysylltu ffug, neu ymatebion ailadroddus",
|
||||
"report.reasons.violation": "Mae'n torri rheolau'r gweinydd",
|
||||
|
@ -801,7 +804,7 @@
|
|||
"report_notification.categories.violation": "Torri rheol",
|
||||
"report_notification.categories.violation_sentence": "torri rheolau",
|
||||
"report_notification.open": "Agor adroddiad",
|
||||
"search.no_recent_searches": "Does dim chwiliadau diweddar",
|
||||
"search.no_recent_searches": "Does dim chwilio diweddar",
|
||||
"search.placeholder": "Chwilio",
|
||||
"search.quick_action.account_search": "Proffiliau sy'n cyfateb i {x}",
|
||||
"search.quick_action.go_to_account": "Mynd i broffil {x}",
|
||||
|
@ -835,13 +838,13 @@
|
|||
"sign_in_banner.mastodon_is": "Mastodon yw'r ffordd orau o gadw i fyny â'r hyn sy'n digwydd.",
|
||||
"sign_in_banner.sign_in": "Mewngofnodi",
|
||||
"sign_in_banner.sso_redirect": "Mewngofnodi neu Gofrestru",
|
||||
"status.admin_account": "Agor rhyngwyneb cymedroli ar gyfer @{name}",
|
||||
"status.admin_account": "Agor rhyngwyneb cymedroli @{name}",
|
||||
"status.admin_domain": "Agor rhyngwyneb cymedroli {domain}",
|
||||
"status.admin_status": "Agor y postiad hwn yn y rhyngwyneb cymedroli",
|
||||
"status.block": "Blocio @{name}",
|
||||
"status.bookmark": "Llyfrnodi",
|
||||
"status.block": "Rhwystro @{name}",
|
||||
"status.bookmark": "Nod tudalen",
|
||||
"status.cancel_reblog_private": "Dadhybu",
|
||||
"status.cannot_reblog": "Nid oes modd hybu'r postiad hwn",
|
||||
"status.cannot_reblog": "Does dim modd hybu'r postiad hwn",
|
||||
"status.continued_thread": "Edefyn parhaus",
|
||||
"status.copy": "Copïo dolen i'r post",
|
||||
"status.delete": "Dileu",
|
||||
|
@ -850,7 +853,7 @@
|
|||
"status.direct_indicator": "Crybwyll preifat",
|
||||
"status.edit": "Golygu",
|
||||
"status.edited": "Golygwyd ddiwethaf {date}",
|
||||
"status.edited_x_times": "Golygwyd {count, plural, one {count} two {count} other {{count} gwaith}}",
|
||||
"status.edited_x_times": "Golygwyd {count, plural, one {{count} gwaith} other {{count} gwaith}}",
|
||||
"status.embed": "Cael y cod mewnblannu",
|
||||
"status.favourite": "Ffafrio",
|
||||
"status.favourites": "{count, plural, one {ffefryn} other {ffefryn}}",
|
||||
|
@ -880,13 +883,13 @@
|
|||
"status.reblogged_by": "Hybodd {name}",
|
||||
"status.reblogs": "{count, plural, one {# hwb} other {# hwb}}",
|
||||
"status.reblogs.empty": "Does neb wedi hybio'r post yma eto. Pan y bydd rhywun yn gwneud, byddent yn ymddangos yma.",
|
||||
"status.redraft": "Dileu ac ailddrafftio",
|
||||
"status.redraft": "Dileu ac ail lunio",
|
||||
"status.remove_bookmark": "Tynnu nod tudalen",
|
||||
"status.remove_favourite": "Tynnu o'r ffefrynnau",
|
||||
"status.replied_in_thread": "Atebodd mewn edefyn",
|
||||
"status.replied_in_thread": "Wedi ateb mewn edefyn",
|
||||
"status.replied_to": "Wedi ymateb i {name}",
|
||||
"status.reply": "Ymateb",
|
||||
"status.replyAll": "Ymateb i edefyn",
|
||||
"status.replyAll": "Ateb edefyn",
|
||||
"status.report": "Adrodd ar @{name}",
|
||||
"status.sensitive_warning": "Cynnwys sensitif",
|
||||
"status.share": "Rhannu",
|
||||
|
@ -910,23 +913,23 @@
|
|||
"time_remaining.days": "{number, plural, one {# diwrnod} other {# diwrnod}} ar ôl",
|
||||
"time_remaining.hours": "{number, plural, one {# awr} other {# awr}} ar ôl",
|
||||
"time_remaining.minutes": "{number, plural, one {# munud} other {# munud}} ar ôl",
|
||||
"time_remaining.moments": "Munudau yn weddill",
|
||||
"time_remaining.moments": "Munudau'n weddill",
|
||||
"time_remaining.seconds": "{number, plural, one {# eiliad} other {# eiliad}} ar ôl",
|
||||
"trends.counter_by_accounts": "{count, plural, zero {neb} one {{counter} person} two {{counter} berson} few {{counter} pherson} other {{counter} o bobl}} yn y {days, plural, one {diwrnod diwethaf} two {ddeuddydd diwethaf} other {{days} diwrnod diwethaf}}",
|
||||
"trends.trending_now": "Pynciau llosg",
|
||||
"trends.trending_now": "Wrthi'n trendio",
|
||||
"ui.beforeunload": "Byddwch yn colli eich drafft os byddwch yn gadael Mastodon.",
|
||||
"units.short.billion": "{count}biliwn",
|
||||
"units.short.million": "{count}miliwn",
|
||||
"units.short.thousand": "{count}mil",
|
||||
"upload_area.title": "Llusgwch a gollwng i lwytho",
|
||||
"upload_button.label": "Ychwanegwch gyfryngau (JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "Wedi pasio'r uchafswm llwytho.",
|
||||
"upload_error.poll": "Nid oes modd llwytho ffeiliau â phleidleisiau.",
|
||||
"upload_button.label": "Ychwanegwch delweddau, fideo neu ffeil sain",
|
||||
"upload_error.limit": "Wedi mynd heibio'r uchafswm llwytho.",
|
||||
"upload_error.poll": "Does dim modd llwytho ffeiliau â phleidleisiau.",
|
||||
"upload_form.drag_and_drop.instructions": "I godi atodiad cyfryngau, pwyswch y space neu enter. Wrth lusgo, defnyddiwch y bysellau saeth i symud yr atodiad cyfryngau i unrhyw gyfeiriad penodol. Pwyswch space neu enter eto i ollwng yr atodiad cyfryngau yn ei safle newydd, neu pwyswch escape i ddiddymu.",
|
||||
"upload_form.drag_and_drop.on_drag_cancel": "Cafodd llusgo ei ddiddymu. Cafodd atodiad cyfryngau {item} ei ollwng.",
|
||||
"upload_form.drag_and_drop.on_drag_end": "Cafodd atodiad cyfryngau {item} ei ollwng.",
|
||||
"upload_form.drag_and_drop.on_drag_cancel": "Cafodd llusgo ei ddiddymu. Cafodd atodi cyfryngau {item} ei ollwng.",
|
||||
"upload_form.drag_and_drop.on_drag_end": "Cafodd atodi cyfryngau {item} ei ollwng.",
|
||||
"upload_form.drag_and_drop.on_drag_over": "Symudwyd atodiad cyfryngau {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_start": "Atodiad cyfryngau godwyd {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_start": "Wedi codi atodiad cyfryngau {item}.",
|
||||
"upload_form.edit": "Golygu",
|
||||
"upload_progress.label": "Yn llwytho...",
|
||||
"upload_progress.processing": "Wrthi'n prosesu…",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Se flere indlæg på {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Der kan mangle svar fra andre servere.",
|
||||
"hints.threads.see_more": "Se flere svar på {domain}",
|
||||
"home.column_settings.show_quotes": "Vis citater",
|
||||
"home.column_settings.show_reblogs": "Vis fremhævelser",
|
||||
"home.column_settings.show_replies": "Vis svar",
|
||||
"home.hide_announcements": "Skjul bekendtgørelser",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Weitere Beiträge auf {domain} ansehen",
|
||||
"hints.threads.replies_may_be_missing": "Möglicherweise werden nicht alle Antworten von anderen Servern angezeigt.",
|
||||
"hints.threads.see_more": "Weitere Antworten auf {domain} ansehen",
|
||||
"home.column_settings.show_quotes": "Zitierte Beiträge anzeigen",
|
||||
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
|
||||
"home.column_settings.show_replies": "Antworten anzeigen",
|
||||
"home.hide_announcements": "Ankündigungen ausblenden",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Moderated servers",
|
||||
"about.contact": "Contact:",
|
||||
"about.default_locale": "Default",
|
||||
"about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Reason not available",
|
||||
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the Fediverse. These are the exceptions that have been made on this particular server.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Limited",
|
||||
"about.domain_blocks.suspended.explanation": "No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.",
|
||||
"about.domain_blocks.suspended.title": "Suspended",
|
||||
"about.language_label": "Language",
|
||||
"about.not_available": "This information has not been made available on this server.",
|
||||
"about.powered_by": "Decentralised social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
|
@ -19,13 +21,21 @@
|
|||
"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_blocking": "Blocking domain",
|
||||
"account.edit_profile": "Edit profile",
|
||||
"account.enable_notifications": "Notify me when @{name} posts",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.familiar_followers_many": "Followed by {name1}, {name2}, and {othersCount, plural, one {one other you know} other {# others you know}}",
|
||||
"account.familiar_followers_one": "Followed by {name1}",
|
||||
"account.familiar_followers_two": "Followed by {name1} and {name2}",
|
||||
"account.featured": "Featured",
|
||||
"account.featured.accounts": "Profiles",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.follow": "Follow",
|
||||
|
@ -33,9 +43,11 @@
|
|||
"account.followers": "Followers",
|
||||
"account.followers.empty": "No one follows this user yet.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
|
||||
"account.followers_you_know_counter": "{counter} you know",
|
||||
"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.",
|
||||
|
@ -50,18 +62,23 @@
|
|||
"account.mute_notifications_short": "Mute notifications",
|
||||
"account.mute_short": "Mute",
|
||||
"account.muted": "Muted",
|
||||
"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}}",
|
||||
"account.unblock": "Unblock @{name}",
|
||||
"account.unblock_domain": "Unblock domain {domain}",
|
||||
"account.unblock_domain_short": "Unblock",
|
||||
"account.unblock_short": "Unblock",
|
||||
"account.unendorse": "Don't feature on profile",
|
||||
"account.unfollow": "Unfollow",
|
||||
|
@ -223,6 +240,9 @@
|
|||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favourites 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?",
|
||||
|
@ -290,6 +310,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 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 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!",
|
||||
|
@ -322,6 +345,11 @@
|
|||
"explore.trending_links": "News",
|
||||
"explore.trending_statuses": "Posts",
|
||||
"explore.trending_tags": "Hashtags",
|
||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||
"featured_carousel.next": "Next",
|
||||
"featured_carousel.post": "Post",
|
||||
"featured_carousel.previous": "Previous",
|
||||
"featured_carousel.slide": "{index} of {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
|
||||
"filter_modal.added.context_mismatch_title": "Context mismatch!",
|
||||
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
||||
|
@ -374,6 +402,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}",
|
||||
|
@ -386,7 +416,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"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.feature": "Feature on profile",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"hashtag.mute": "Mute #{hashtag}",
|
||||
"hashtag.unfeature": "Don't feature on profile",
|
||||
"hashtag.unfollow": "Unfollow hashtag",
|
||||
"hashtags.and_other": "…and {count, plural, one {one more} other {# more}}",
|
||||
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
|
||||
|
@ -397,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
|
||||
"hints.threads.see_more": "See more replies on {domain}",
|
||||
"home.column_settings.show_quotes": "Show quotes",
|
||||
"home.column_settings.show_reblogs": "Show boosts",
|
||||
"home.column_settings.show_replies": "Show replies",
|
||||
"home.hide_announcements": "Hide announcements",
|
||||
|
@ -837,6 +871,13 @@
|
|||
"status.mute_conversation": "Mute conversation",
|
||||
"status.open": "Expand this post",
|
||||
"status.pin": "Pin on profile",
|
||||
"status.quote_error.filtered": "Hidden due to one of your filters",
|
||||
"status.quote_error.not_found": "This post cannot be displayed.",
|
||||
"status.quote_error.pending_approval": "This post is pending approval from the original author.",
|
||||
"status.quote_error.rejected": "This post cannot be displayed as the original author does not allow it to be quoted.",
|
||||
"status.quote_error.removed": "This post was removed by its author.",
|
||||
"status.quote_error.unauthorized": "This post cannot be displayed as you are not authorised",
|
||||
"status.quote_post_author": "Post by {name}",
|
||||
"status.read_more": "Read more",
|
||||
"status.reblog": "Boost",
|
||||
"status.reblog_private": "Boost with original visibility",
|
||||
|
@ -867,7 +908,9 @@
|
|||
"subscribed_languages.target": "Change subscribed languages for {target}",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"terms_of_service.effective_as_of": "Effective as of {date}",
|
||||
"terms_of_service.title": "Terms of Service",
|
||||
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
||||
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
|
||||
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
|
||||
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
|
||||
|
@ -898,6 +941,12 @@
|
|||
"video.expand": "Expand video",
|
||||
"video.fullscreen": "Full screen",
|
||||
"video.hide": "Hide video",
|
||||
"video.mute": "Mute",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Play"
|
||||
"video.play": "Play",
|
||||
"video.skip_backward": "Skip backward",
|
||||
"video.skip_forward": "Skip forward",
|
||||
"video.unmute": "Unmute",
|
||||
"video.volume_down": "Volume down",
|
||||
"video.volume_up": "Volume up"
|
||||
}
|
||||
|
|
|
@ -336,7 +336,6 @@
|
|||
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
|
||||
"compose_form.poll.type": "Style",
|
||||
"compose_form.publish": "Post",
|
||||
"compose_form.publish_form": "New post",
|
||||
"compose_form.reply": "Reply",
|
||||
"compose_form.save_changes": "Update",
|
||||
"compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.",
|
||||
|
@ -575,6 +574,7 @@
|
|||
"hints.profiles.see_more_posts": "See more posts on {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
|
||||
"hints.threads.see_more": "See more replies on {domain}",
|
||||
"home.column_settings.show_quotes": "Show quotes",
|
||||
"home.column_settings.show_reblogs": "Show boosts",
|
||||
"home.column_settings.show_replies": "Show replies",
|
||||
"home.hide_announcements": "Hide announcements",
|
||||
|
@ -705,9 +705,11 @@
|
|||
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
||||
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
|
||||
"navigation_bar.about": "About",
|
||||
"navigation_bar.account_settings": "Password and security",
|
||||
"navigation_bar.administration": "Administration",
|
||||
"navigation_bar.advanced_interface": "Open in advanced web interface",
|
||||
"navigation_bar.antennas": "Antenna",
|
||||
"navigation_bar.automated_deletion": "Automated post deletion",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
"navigation_bar.bookmarks": "Bookmarks",
|
||||
"navigation_bar.circles": "Circles",
|
||||
|
@ -717,26 +719,30 @@
|
|||
"navigation_bar.direct": "Private mentions",
|
||||
"navigation_bar.discover": "Discover",
|
||||
"navigation_bar.domain_blocks": "Blocked domains",
|
||||
"navigation_bar.emoji_reactions": "Stamps",
|
||||
"navigation_bar.emoji_reactions": "Emoji reactions",
|
||||
"navigation_bar.explore": "Explore",
|
||||
"navigation_bar.favourites": "Favorites",
|
||||
"navigation_bar.filters": "Muted words",
|
||||
"navigation_bar.follow_requests": "Follow requests",
|
||||
"navigation_bar.followed_tags": "Followed hashtags",
|
||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||
"navigation_bar.import_export": "Import and export",
|
||||
"navigation_bar.lists": "Lists",
|
||||
"navigation_bar.logout": "Logout",
|
||||
"navigation_bar.moderation": "Moderation",
|
||||
"navigation_bar.more": "More",
|
||||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Pinned posts",
|
||||
"navigation_bar.preferences": "Preferences",
|
||||
"navigation_bar.privacy_and_reach": "Privacy and reach",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.reaction_deck": "Reaction deck",
|
||||
"navigation_bar.refresh": "Refresh",
|
||||
"navigation_bar.search": "Search",
|
||||
"navigation_bar.security": "Security",
|
||||
"navigation_panel.collapse_lists": "Collapse list menu",
|
||||
"navigation_panel.expand_lists": "Expand list menu",
|
||||
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
|
||||
"notification.admin.report": "{name} reported {target}",
|
||||
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
|
||||
|
@ -1121,7 +1127,10 @@
|
|||
"subscribed_languages.save": "Save changes",
|
||||
"subscribed_languages.target": "Change subscribed languages for {target}",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.menu": "Menu",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"tabs_bar.publish": "New Post",
|
||||
"tabs_bar.search": "Search",
|
||||
"terms_of_service.effective_as_of": "Effective as of {date}",
|
||||
"terms_of_service.title": "Terms of Service",
|
||||
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Ver más mensajes en {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Es posible que falten respuestas de otros servidores.",
|
||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar citas",
|
||||
"home.column_settings.show_reblogs": "Mostrar adhesiones",
|
||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||
"home.hide_announcements": "Ocultar anuncios",
|
||||
|
|
|
@ -345,9 +345,9 @@
|
|||
"explore.trending_links": "Noticias",
|
||||
"explore.trending_statuses": "Publicaciones",
|
||||
"explore.trending_tags": "Etiquetas",
|
||||
"featured_carousel.header": "{count, plural,one {Publicación fijada} other {Publicaciones fijada}}",
|
||||
"featured_carousel.header": "{count, plural,one {Publicación fijada} other {Publicaciones fijadas}}",
|
||||
"featured_carousel.next": "Siguiente",
|
||||
"featured_carousel.post": "Publicar",
|
||||
"featured_carousel.post": "Publicación",
|
||||
"featured_carousel.previous": "Anterior",
|
||||
"featured_carousel.slide": "{index} de {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que has accedido a esta publlicación. Si quieres que la publicación sea filtrada también en este contexto, tendrás que editar el filtro.",
|
||||
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Puede que no se muestren algunas respuestas de otros servidores.",
|
||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar citas",
|
||||
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||
"home.hide_announcements": "Ocultar anuncios",
|
||||
|
|
|
@ -345,9 +345,9 @@
|
|||
"explore.trending_links": "Noticias",
|
||||
"explore.trending_statuses": "Publicaciones",
|
||||
"explore.trending_tags": "Etiquetas",
|
||||
"featured_carousel.header": "{count, plural,one {Publicación fijada} other {Publicaciones fijada}}",
|
||||
"featured_carousel.header": "{count, plural,one {Publicación fijada} other {Publicaciones fijadas}}",
|
||||
"featured_carousel.next": "Siguiente",
|
||||
"featured_carousel.post": "Publicar",
|
||||
"featured_carousel.post": "Publicación",
|
||||
"featured_carousel.previous": "Anterior",
|
||||
"featured_carousel.slide": "{index} de {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que ha accedido a esta publlicación. Si quieres que la publicación sea filtrada también en este contexto, tendrás que editar el filtro.",
|
||||
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Ver más publicaciones en {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Puede que no se muestren algunas respuestas de otros servidores.",
|
||||
"hints.threads.see_more": "Ver más respuestas en {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar citas",
|
||||
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
||||
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||
"home.hide_announcements": "Ocultar comunicaciones",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Modereeritavad serverid",
|
||||
"about.contact": "Kontakt:",
|
||||
"about.default_locale": "Vaikimisi",
|
||||
"about.disclaimer": "Mastodon on tasuta ja vaba tarkvara ning Mastodon gGmbH kaubamärk.",
|
||||
"about.domain_blocks.no_reason_available": "Põhjus teadmata",
|
||||
"about.domain_blocks.preamble": "Mastodon lubab tavaliselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest fediversumi serverist. Need on erandid, mis on paika pandud sellel kindlal serveril.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Piiratud",
|
||||
"about.domain_blocks.suspended.explanation": "Mitte mingeid andmeid sellelt serverilt ei töödelda, salvestata ega vahetata, tehes igasuguse interaktsiooni või kirjavahetuse selle serveri kasutajatega võimatuks.",
|
||||
"about.domain_blocks.suspended.title": "Peatatud",
|
||||
"about.language_label": "Keel",
|
||||
"about.not_available": "See info ei ole sellel serveril saadavaks tehtud.",
|
||||
"about.powered_by": "Hajutatud sotsiaalmeedia, mille taga on {mastodon}",
|
||||
"about.rules": "Serveri reeglid",
|
||||
|
@ -26,6 +28,12 @@
|
|||
"account.edit_profile": "Muuda profiili",
|
||||
"account.enable_notifications": "Teavita mind @{name} postitustest",
|
||||
"account.endorse": "Too profiilil esile",
|
||||
"account.familiar_followers_many": "Jälgijateks {name1}, {name2} ja veel {othersCount, plural, one {üks kasutaja, keda tead} other {# kasutajat, keda tead}}",
|
||||
"account.familiar_followers_one": "Jälgijaks {name1}",
|
||||
"account.familiar_followers_two": "Jälgijateks {name1} ja {name2}",
|
||||
"account.featured": "Esiletõstetud",
|
||||
"account.featured.accounts": "Profiilid",
|
||||
"account.featured.hashtags": "Sildid",
|
||||
"account.featured_tags.last_status_at": "Viimane postitus {date}",
|
||||
"account.featured_tags.last_status_never": "Postitusi pole",
|
||||
"account.follow": "Jälgi",
|
||||
|
@ -36,6 +44,7 @@
|
|||
"account.following": "Jälgib",
|
||||
"account.following_counter": "{count, plural, one {{counter} jälgib} other {{counter} jälgib}}",
|
||||
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
|
||||
"account.follows_you": "Jälgib sind",
|
||||
"account.go_to_profile": "Mine profiilile",
|
||||
"account.hide_reblogs": "Peida @{name} jagamised",
|
||||
"account.in_memoriam": "In Memoriam.",
|
||||
|
@ -50,18 +59,22 @@
|
|||
"account.mute_notifications_short": "Vaigista teavitused",
|
||||
"account.mute_short": "Vaigista",
|
||||
"account.muted": "Vaigistatud",
|
||||
"account.mutual": "Te jälgite teineteist",
|
||||
"account.no_bio": "Kirjeldust pole lisatud.",
|
||||
"account.open_original_page": "Ava algne leht",
|
||||
"account.posts": "Postitused",
|
||||
"account.posts_with_replies": "Postitused ja vastused",
|
||||
"account.remove_from_followers": "Eemalda {name} jälgijate seast",
|
||||
"account.report": "Raporteeri @{name}",
|
||||
"account.requested": "Ootab kinnitust. Klõpsa jälgimise soovi tühistamiseks",
|
||||
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
|
||||
"account.requests_to_follow_you": "soovib sind jälgida",
|
||||
"account.share": "Jaga @{name} profiili",
|
||||
"account.show_reblogs": "Näita @{name} jagamisi",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
|
||||
"account.unblock": "Eemalda blokeering @{name}",
|
||||
"account.unblock_domain": "Tee {domain} nähtavaks",
|
||||
"account.unblock_domain_short": "Lõpeta blokeerimine",
|
||||
"account.unblock_short": "Eemalda blokeering",
|
||||
"account.unendorse": "Ära kuva profiilil",
|
||||
"account.unfollow": "Jälgid",
|
||||
|
@ -223,6 +236,9 @@
|
|||
"confirmations.redraft.confirm": "Kustuta & taasalusta",
|
||||
"confirmations.redraft.message": "Kindel, et soovid postituse kustutada ja võtta uue aluseks? Lemmikuks märkimised ja jagamised lähevad kaotsi ning vastused jäävad ilma algse postituseta.",
|
||||
"confirmations.redraft.title": "Kustudada ja luua postituse mustand?",
|
||||
"confirmations.remove_from_followers.confirm": "Eemalda jälgija",
|
||||
"confirmations.remove_from_followers.message": "{name} lõpetab sellega sinu jälgimise. Kas oled kindel, et soovid jätkata?",
|
||||
"confirmations.remove_from_followers.title": "Kas eemaldame jälgija?",
|
||||
"confirmations.reply.confirm": "Vasta",
|
||||
"confirmations.reply.message": "Praegu vastamine kirjutab hetkel koostatava sõnumi üle. Oled kindel, et soovid jätkata?",
|
||||
"confirmations.reply.title": "Kirjutada postitus üle?",
|
||||
|
@ -290,6 +306,9 @@
|
|||
"emoji_button.search_results": "Otsitulemused",
|
||||
"emoji_button.symbols": "Sümbolid",
|
||||
"emoji_button.travel": "Reisimine & kohad",
|
||||
"empty_column.account_featured.me": "Sa pole veel midagi esile tõstnud. Kas sa teadsid, et oma profiilis saad esile tõsta enamkasutatavaid silte või või sõbra kasutajakontot?",
|
||||
"empty_column.account_featured.other": "{acct} pole veel midagi esile tõstnud. Kas sa teadsid, et oma profiilis saad esile tõsta enamkasutatavaid silte või või sõbra kasutajakontot?",
|
||||
"empty_column.account_featured_other.unknown": "See kasutajakonto pole veel midagi esile tõstnud.",
|
||||
"empty_column.account_hides_collections": "See kasutaja otsustas mitte teha seda infot saadavaks",
|
||||
"empty_column.account_suspended": "Konto kustutatud",
|
||||
"empty_column.account_timeline": "Siin postitusi ei ole!",
|
||||
|
@ -322,6 +341,10 @@
|
|||
"explore.trending_links": "Uudised",
|
||||
"explore.trending_statuses": "Postitused",
|
||||
"explore.trending_tags": "Sildid",
|
||||
"featured_carousel.header": "{count, plural, one {Esiletõstetud postitus} other {Esiletõstetud postitust}}",
|
||||
"featured_carousel.next": "Järgmine",
|
||||
"featured_carousel.previous": "Eelmine",
|
||||
"featured_carousel.slide": "{index} / {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "See filtrikategooria ei rakendu kontekstis, kuidas postituseni jõudsid. Kui tahad postitust ka selles kontekstis filtreerida, pead muutma filtrit.",
|
||||
"filter_modal.added.context_mismatch_title": "Konteksti mittesobivus!",
|
||||
"filter_modal.added.expired_explanation": "Selle filtri kategooria on aegunud. pead muutma aegumiskuupäeva, kui tahad, et filter kehtiks.",
|
||||
|
@ -374,6 +397,8 @@
|
|||
"generic.saved": "Salvestatud",
|
||||
"getting_started.heading": "Alustamine",
|
||||
"hashtag.admin_moderation": "Ava modereerimisliides #{name} jaoks",
|
||||
"hashtag.browse": "Sirvi #{hashtag} sildiga postitusi",
|
||||
"hashtag.browse_from_account": "Sirvi @{name} kasutaja #{hashtag} sildiga postitusi",
|
||||
"hashtag.column_header.tag_mode.all": "ja {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "või {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "ilma {additional}",
|
||||
|
@ -386,7 +411,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} osalejaga} other {{counter} osalejaga}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} postitusega} other {{counter} postitusega}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} postitust} other {{counter} postitust}} täna",
|
||||
"hashtag.feature": "Tõsta profiilis esile",
|
||||
"hashtag.follow": "Jälgi silti",
|
||||
"hashtag.mute": "Vaigista @#{hashtag}",
|
||||
"hashtag.unfeature": "Ära tõsta profiilis esile",
|
||||
"hashtag.unfollow": "Lõpeta sildi jälgimine",
|
||||
"hashtags.and_other": "…ja {count, plural, one {}other {# veel}}",
|
||||
"hints.profiles.followers_may_be_missing": "Selle profiili jälgijaid võib olla puudu.",
|
||||
|
@ -397,6 +425,7 @@
|
|||
"hints.profiles.see_more_posts": "Vaata rohkem postitusi kohas {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Vastuseid teistest serveritest võib olla puudu.",
|
||||
"hints.threads.see_more": "Vaata rohkem vastuseid kohas {domain}",
|
||||
"home.column_settings.show_quotes": "Näita tsiteeritut",
|
||||
"home.column_settings.show_reblogs": "Näita jagamisi",
|
||||
"home.column_settings.show_replies": "Näita vastuseid",
|
||||
"home.hide_announcements": "Peida teadaanded",
|
||||
|
@ -705,6 +734,8 @@
|
|||
"privacy_policy.title": "Isikuandmete kaitse",
|
||||
"recommended": "Soovitatud",
|
||||
"refresh": "Värskenda",
|
||||
"regeneration_indicator.please_stand_by": "Palun oota.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Valmistan ette sinu avalehe lõime…",
|
||||
"relative_time.days": "{number}p",
|
||||
"relative_time.full.days": "{number, plural, one {# päev} other {# päeva}} tagasi",
|
||||
"relative_time.full.hours": "{number, plural, one {# tund} other {# tundi}} tagasi",
|
||||
|
@ -758,7 +789,7 @@
|
|||
"report.thanks.title": "Ei taha seda näha?",
|
||||
"report.thanks.title_actionable": "Täname teavitamise eest, uurime seda.",
|
||||
"report.unfollow": "Lõpeta @{name} jälgimine",
|
||||
"report.unfollow_explanation": "Jälgid seda kontot. Et mitte näha tema postitusi oma koduvoos, lõpeta ta jälgimine.",
|
||||
"report.unfollow_explanation": "Jälgid seda kontot. Et mitte näha tema postitusi oma avalehe lõimes, lõpeta ta jälgimine.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} postitus} other {{count} postitust}} listatud",
|
||||
"report_notification.categories.legal": "Õiguslik",
|
||||
"report_notification.categories.legal_sentence": "ebaseaduslik sisu",
|
||||
|
@ -788,8 +819,11 @@
|
|||
"search_results.accounts": "Profiilid",
|
||||
"search_results.all": "Kõik",
|
||||
"search_results.hashtags": "Sildid",
|
||||
"search_results.no_results": "Tulemusi pole.",
|
||||
"search_results.no_search_yet": "Proovi otsida postitusi, profiile või silte.",
|
||||
"search_results.see_all": "Vaata kõiki",
|
||||
"search_results.statuses": "Postitused",
|
||||
"search_results.title": "Otsi märksõna: {q}",
|
||||
"server_banner.about_active_users": "Inimesed, kes kasutavad seda serverit viimase 30 päeva jooksul (kuu aktiivsed kasutajad)",
|
||||
"server_banner.active_users": "aktiivsed kasutajad",
|
||||
"server_banner.administered_by": "Administraator:",
|
||||
|
@ -832,6 +866,13 @@
|
|||
"status.mute_conversation": "Vaigista vestlus",
|
||||
"status.open": "Laienda postitus",
|
||||
"status.pin": "Kinnita profiilile",
|
||||
"status.quote_error.filtered": "Peidetud mõne kasutatud filtri tõttu",
|
||||
"status.quote_error.not_found": "Seda postitust ei saa näidata.",
|
||||
"status.quote_error.pending_approval": "See postitus on algse autori kinnituse ootel.",
|
||||
"status.quote_error.rejected": "Seda postitust ei saa näidata, kuina algne autor ei luba teda tsiteerida.",
|
||||
"status.quote_error.removed": "Autor kustutas selle postituse.",
|
||||
"status.quote_error.unauthorized": "Kuna sul pole luba selle postituse nägemiseks, siis seda ei saa kuvada.",
|
||||
"status.quote_post_author": "Postitajaks {name}",
|
||||
"status.read_more": "Loe veel",
|
||||
"status.reblog": "Jaga",
|
||||
"status.reblog_private": "Jaga algse nähtavusega",
|
||||
|
@ -840,6 +881,7 @@
|
|||
"status.reblogs.empty": "Keegi pole seda postitust veel jaganud. Kui keegi seda teeb, näeb seda siin.",
|
||||
"status.redraft": "Kustuta & alga uuesti",
|
||||
"status.remove_bookmark": "Eemalda järjehoidja",
|
||||
"status.remove_favourite": "Eemalda lemmikute seast",
|
||||
"status.replied_in_thread": "Vastatud lõimes",
|
||||
"status.replied_to": "Vastas kasutajale {name}",
|
||||
"status.reply": "Vasta",
|
||||
|
@ -861,7 +903,9 @@
|
|||
"subscribed_languages.target": "Muuda tellitud keeli {target} jaoks",
|
||||
"tabs_bar.home": "Kodu",
|
||||
"tabs_bar.notifications": "Teated",
|
||||
"terms_of_service.effective_as_of": "Kehtib alates {date}",
|
||||
"terms_of_service.title": "Teenuse tingimused",
|
||||
"terms_of_service.upcoming_changes_on": "Muudatused alates {date}",
|
||||
"time_remaining.days": "{number, plural, one {# päev} other {# päeva}} jäänud",
|
||||
"time_remaining.hours": "{number, plural, one {# tund} other {# tundi}} jäänud",
|
||||
"time_remaining.minutes": "{number, plural, one {# minut} other {# minutit}} jäänud",
|
||||
|
@ -892,6 +936,12 @@
|
|||
"video.expand": "Suurenda video",
|
||||
"video.fullscreen": "Täisekraan",
|
||||
"video.hide": "Peida video",
|
||||
"video.mute": "Vaigista",
|
||||
"video.pause": "Paus",
|
||||
"video.play": "Mängi"
|
||||
"video.play": "Mängi",
|
||||
"video.skip_backward": "Keri tagasi",
|
||||
"video.skip_forward": "Keri edasi",
|
||||
"video.unmute": "Lõpeta vaigistamine",
|
||||
"video.volume_down": "Heli vaiksemaks",
|
||||
"video.volume_up": "Heli valjemaks"
|
||||
}
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Näytä lisää julkaisuja palvelimella {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Muiden palvelinten vastauksia saattaa puuttua.",
|
||||
"hints.threads.see_more": "Näytä lisää vastauksia palvelimella {domain}",
|
||||
"home.column_settings.show_quotes": "Näytä lainaukset",
|
||||
"home.column_settings.show_reblogs": "Näytä tehostukset",
|
||||
"home.column_settings.show_replies": "Näytä vastaukset",
|
||||
"home.hide_announcements": "Piilota tiedotteet",
|
||||
|
|
|
@ -430,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Sí fleiri postar á {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Svar frá øðrum ambætarum mangla møguliga.",
|
||||
"hints.threads.see_more": "Sí fleiri svar á {domain}",
|
||||
"home.column_settings.show_quotes": "Vís siteringar",
|
||||
"home.column_settings.show_reblogs": "Vís lyft",
|
||||
"home.column_settings.show_replies": "Vís svar",
|
||||
"home.hide_announcements": "Fjal kunngerðir",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Freastalaithe faoi stiúir",
|
||||
"about.contact": "Teagmháil:",
|
||||
"about.default_locale": "Réamhshocrú",
|
||||
"about.disclaimer": "Bogearra foinse oscailte saor in aisce is ea Mastodon, agus is le Mastodon gGmbH an trádmharc.",
|
||||
"about.domain_blocks.no_reason_available": "Níl an fáth ar fáil",
|
||||
"about.domain_blocks.preamble": "Go hiondúil, tugann Mastadán cead duit a bheith ag plé le húsáideoirí as freastalaí ar bith eile sa chomhchruinne agus a gcuid inneachair a fheiceáil. Seo iad na heisceachtaí a rinneadh ar an bhfreastalaí áirithe seo.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Teoranta",
|
||||
"about.domain_blocks.suspended.explanation": "Ní dhéanfar aon sonra ón fhreastalaí seo a phróiseáil, a stóráil ná a mhalartú, rud a fhágann nach féidir aon teagmháil ná aon chumarsáid a dhéanamh le húsáideoirí ón fhreastalaí seo.",
|
||||
"about.domain_blocks.suspended.title": "Ar fionraí",
|
||||
"about.language_label": "Teanga",
|
||||
"about.not_available": "Níor cuireadh an t-eolas seo ar fáil ar an bhfreastalaí seo.",
|
||||
"about.powered_by": "Meáin shóisialta díláraithe faoi chumhacht {mastodon}",
|
||||
"about.rules": "Rialacha an fhreastalaí",
|
||||
|
@ -308,6 +310,8 @@
|
|||
"emoji_button.search_results": "Torthaí cuardaigh",
|
||||
"emoji_button.symbols": "Comharthaí",
|
||||
"emoji_button.travel": "Taisteal ⁊ Áiteanna",
|
||||
"empty_column.account_featured.me": "Níl aon rud curtha i láthair agat go fóill. An raibh a fhios agat gur féidir leat na haischlibeanna is mó a úsáideann tú, agus fiú cuntais do chairde, a chur i láthair ar do phróifíl?",
|
||||
"empty_column.account_featured.other": "Níl aon rud feicthe ag {acct} go fóill. An raibh a fhios agat gur féidir leat na hashtags is mó a úsáideann tú, agus fiú cuntais do chairde, a chur ar do phróifíl?",
|
||||
"empty_column.account_featured_other.unknown": "Níl aon rud le feiceáil sa chuntas seo go fóill.",
|
||||
"empty_column.account_hides_collections": "Roghnaigh an t-úsáideoir seo gan an fhaisnéis seo a chur ar fáil",
|
||||
"empty_column.account_suspended": "Cuntas ar fionraí",
|
||||
|
@ -341,6 +345,11 @@
|
|||
"explore.trending_links": "Nuacht",
|
||||
"explore.trending_statuses": "Postálacha",
|
||||
"explore.trending_tags": "Haischlibeanna",
|
||||
"featured_carousel.header": "{count, plural, one {Postáil phinnáilte} two {Poist Phionáilte} few {Poist Phionáilte} many {Poist Phionáilte} other {Poist Phionáilte}}",
|
||||
"featured_carousel.next": "Ar Aghaidh",
|
||||
"featured_carousel.post": "Post",
|
||||
"featured_carousel.previous": "Roimhe Seo",
|
||||
"featured_carousel.slide": "{index} de {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Ní bhaineann an chatagóir scagaire seo leis an gcomhthéacs ina bhfuair tú rochtain ar an bpostáil seo. Más mian leat an postáil a scagadh sa chomhthéacs seo freisin, beidh ort an scagaire a chur in eagar.",
|
||||
"filter_modal.added.context_mismatch_title": "Neamhréir comhthéacs!",
|
||||
"filter_modal.added.expired_explanation": "Tá an chatagóir scagaire seo imithe in éag, beidh ort an dáta éaga a athrú chun é a chur i bhfeidhm.",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Frithealaichean fo mhaorsainneachd",
|
||||
"about.contact": "Fios thugainn:",
|
||||
"about.default_locale": "Bun-roghainn",
|
||||
"about.disclaimer": "’S e bathar-bog saor le bun-tùs fosgailte a th’ ann am Mastodon agus ’na chomharra-mhalairt aig Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Chan eil an t-adhbhar ga thoirt seachad",
|
||||
"about.domain_blocks.preamble": "San fharsaingeachd, leigidh Mastodon leat susbaint o fhrithealaiche sam bith sa cho-shaoghal a shealltainn agus eadar-ghìomh a ghabhail leis na cleachdaichean uapa-san. Seo na h-easgaidhean a tha an sàs air an fhrithealaiche shònraichte seo.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Cuingichte",
|
||||
"about.domain_blocks.suspended.explanation": "Cha dèid dàta sam bith on fhrithealaiche seo a phròiseasadh, a stòradh no iomlaid agus chan urrainn do na cleachdaichean on fhrithealaiche sin conaltradh no eadar-ghnìomh a ghabhail an-seo.",
|
||||
"about.domain_blocks.suspended.title": "À rèim",
|
||||
"about.language_label": "Cànan",
|
||||
"about.not_available": "Cha deach am fiosrachadh seo a sholar air an fhrithealaiche seo.",
|
||||
"about.powered_by": "Lìonra sòisealta sgaoilte le cumhachd {mastodon}",
|
||||
"about.rules": "Riaghailtean an fhrithealaiche",
|
||||
|
@ -28,6 +30,9 @@
|
|||
"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.familiar_followers_many": "’Ga leantainn le {name1}, {name2}, and {othersCount, plural, one {# eile air a bheil thu eòlach} other {# eile air a bheil thu eòlach}}",
|
||||
"account.familiar_followers_one": "’Ga leantainn le {name1}",
|
||||
"account.familiar_followers_two": "’Ga leantainn le {name1} ’s {name2}",
|
||||
"account.featured": "’Ga bhrosnachadh",
|
||||
"account.featured.accounts": "Pròifilean",
|
||||
"account.featured.hashtags": "Tagaichean hais",
|
||||
|
@ -38,6 +43,7 @@
|
|||
"account.followers": "Luchd-leantainn",
|
||||
"account.followers.empty": "Chan eil neach sam bith a’ leantainn air a’ chleachdaiche seo fhathast.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} neach-leantainn} other {{counter} luchd-leantainn}}",
|
||||
"account.followers_you_know_counter": "{counter} air a bheil thu eòlach",
|
||||
"account.following": "A’ leantainn",
|
||||
"account.following_counter": "{count, plural, one {A’ leantainn {counter}} other {A’ leantainn {counter}}}",
|
||||
"account.follows.empty": "Chan eil an cleachdaiche seo a’ leantainn neach sam bith fhathast.",
|
||||
|
@ -304,6 +310,8 @@
|
|||
"emoji_button.search_results": "Toraidhean an luirg",
|
||||
"emoji_button.symbols": "Samhlaidhean",
|
||||
"emoji_button.travel": "Siubhal ⁊ àitichean",
|
||||
"empty_column.account_featured.me": "Chan eil thu a’ brosnachadh dad fhathast. An robh fios agad gur urrainn dhut na tagaichean hais a chleachdas tu as trice agus fiù ’s cunntasan do charaidean a bhrosnachadh air a’ phròifil agad?",
|
||||
"empty_column.account_featured.other": "Chan eil {acct} a’ brosnachadh dad fhathast. An robh fios agad gur urrainn dhut na tagaichean hais a chleachdas tu as trice agus fiù ’s cunntasan do charaidean a bhrosnachadh air a’ phròifil agad?",
|
||||
"empty_column.account_featured_other.unknown": "Chan eil an cunntas seo a’ brosnachadh dad fhathast.",
|
||||
"empty_column.account_hides_collections": "Chuir an cleachdaiche seo roimhe nach eil am fiosrachadh seo ri fhaighinn",
|
||||
"empty_column.account_suspended": "Chaidh an cunntas a chur à rèim",
|
||||
|
@ -337,6 +345,11 @@
|
|||
"explore.trending_links": "Naidheachdan",
|
||||
"explore.trending_statuses": "Postaichean",
|
||||
"explore.trending_tags": "Tagaichean hais",
|
||||
"featured_carousel.header": "{count, plural, one {Post prìnichte} two {Postaichean prìnichte} few {Postaichean prìnichte} other {Postaichean prìnichte}}",
|
||||
"featured_carousel.next": "Air adhart",
|
||||
"featured_carousel.post": "Post",
|
||||
"featured_carousel.previous": "Air ais",
|
||||
"featured_carousel.slide": "{index} à {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Chan eil an roinn-seòrsa criathraidh iom seo chaidh dhan cho-theacs san do dh’inntrig thu am post seo. Ma tha thu airson am post a chriathradh sa cho-theacs seo cuideachd, feumaidh tu a’ chriathrag a dheasachadh.",
|
||||
"filter_modal.added.context_mismatch_title": "Co-theacsa neo-iomchaidh!",
|
||||
"filter_modal.added.expired_explanation": "Dh’fhalbh an ùine air an roinn-seòrsa criathraidh seo agus feumaidh tu an ceann-là crìochnachaidh atharrachadh mus cuir thu an sàs i.",
|
||||
|
@ -856,6 +869,13 @@
|
|||
"status.mute_conversation": "Mùch an còmhradh",
|
||||
"status.open": "Leudaich am post seo",
|
||||
"status.pin": "Prìnich ris a’ phròifil",
|
||||
"status.quote_error.filtered": "Falaichte le criathrag a th’ agad",
|
||||
"status.quote_error.not_found": "Chan urrainn dhuinn am post seo a shealltainn.",
|
||||
"status.quote_error.pending_approval": "Tha am post seo a’ feitheamh air aontachadh leis an ùghdar tùsail.",
|
||||
"status.quote_error.rejected": "Chan urrainn dhuinn am post seo a shealltainn air sgàth ’s nach ceadaich an t-ùghdar tùsail aige gun dèid a luaidh.",
|
||||
"status.quote_error.removed": "Chaidh am post seo a thoirt air falbh le ùghdar.",
|
||||
"status.quote_error.unauthorized": "Chan urrainn dhuinn am post seo a shealltainn air sgàth ’s nach eil cead agad fhaicinn.",
|
||||
"status.quote_post_author": "Post le {name}",
|
||||
"status.read_more": "Leugh an còrr",
|
||||
"status.reblog": "Brosnaich",
|
||||
"status.reblog_private": "Brosnaich leis an t-so-fhaicsinneachd tùsail",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Servidores suxeitos a moderación",
|
||||
"about.contact": "Contacto:",
|
||||
"about.default_locale": "Por defecto",
|
||||
"about.disclaimer": "Mastodon é software libre, de código aberto, e unha marca comercial de Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Motivo non indicado",
|
||||
"about.domain_blocks.preamble": "Mastodon de xeito xeral permíteche ver contidos doutros servidores do fediverso e interactuar coas súas usuarias. Estas son as excepcións que se estabeleceron neste servidor en particular.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Limitado",
|
||||
"about.domain_blocks.suspended.explanation": "Non se procesarán, almacenarán nin intercambiarán datos con este servidor, o que fai imposible calquera interacción ou comunicación coas usuarias deste servidor.",
|
||||
"about.domain_blocks.suspended.title": "Suspendido",
|
||||
"about.language_label": "Idioma",
|
||||
"about.not_available": "Esta información non está dispoñible neste servidor.",
|
||||
"about.powered_by": "Comunicación social descentralizada grazas a {mastodon}",
|
||||
"about.rules": "Regras do servidor",
|
||||
|
@ -428,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "Mira máis publicacións en {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Poderían faltar respostas desde outros servidores.",
|
||||
"hints.threads.see_more": "Mira máis respostas en {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar citas",
|
||||
"home.column_settings.show_reblogs": "Amosar compartidos",
|
||||
"home.column_settings.show_replies": "Amosar respostas",
|
||||
"home.hide_announcements": "Agochar anuncios",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "שרתים תחת פיקוח תוכן",
|
||||
"about.contact": "יצירת קשר:",
|
||||
"about.default_locale": "ברירת המחדל",
|
||||
"about.disclaimer": "מסטודון היא תוכנת קוד פתוח חינמית וסימן מסחרי של Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "הסיבה אינה זמינה",
|
||||
"about.domain_blocks.preamble": "ככלל מסטודון מאפשרת לך לצפות בתוכן ולתקשר עם משתמשים מכל שרת בפדיברס. אלו הם היוצאים מן הכלל שהוגדרו עבור השרת המסוים הזה.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "מוגבלים",
|
||||
"about.domain_blocks.suspended.explanation": "שום מידע משרת זה לא יעובד, יישמר או יוחלף, מה שהופך כל תקשורת עם משתמשים משרת זה לבלתי אפשרית.",
|
||||
"about.domain_blocks.suspended.title": "מושעים",
|
||||
"about.language_label": "שפה",
|
||||
"about.not_available": "המידע אינו זמין על שרת זה.",
|
||||
"about.powered_by": "רשת חברתית מבוזרת המופעלת על ידי {mastodon}",
|
||||
"about.rules": "כללי השרת",
|
||||
|
@ -308,6 +310,8 @@
|
|||
"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": "חשבון מושעה",
|
||||
|
@ -341,6 +345,11 @@
|
|||
"explore.trending_links": "חדשות",
|
||||
"explore.trending_statuses": "הודעות",
|
||||
"explore.trending_tags": "תגיות",
|
||||
"featured_carousel.header": "{count, plural, one {הודעה אחת נעוצה} two {הודעותיים נעוצות} many {הודעות נעוצות} other {הודעות נעוצות}}",
|
||||
"featured_carousel.next": "הבא",
|
||||
"featured_carousel.post": "הודעה",
|
||||
"featured_carousel.previous": "הקודם",
|
||||
"featured_carousel.slide": "{index} מתוך {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "קטגוריית המסנן הזאת לא חלה על ההקשר שממנו הגעת אל ההודעה הזו. אם תרצה/י שההודעה תסונן גם בהקשר זה, תצטרך/י לערוך את הסנן.",
|
||||
"filter_modal.added.context_mismatch_title": "אין התאמה להקשר!",
|
||||
"filter_modal.added.expired_explanation": "פג תוקפה של קטגוריית הסינון הזו, יש צורך לשנות את תאריך התפוגה כדי שהסינון יוחל.",
|
||||
|
@ -421,6 +430,7 @@
|
|||
"hints.profiles.see_more_posts": "צפיה בעוד פרסומים בשרת {domain}",
|
||||
"hints.threads.replies_may_be_missing": "תגובות משרתים אחרים עלולות להיות חסרות.",
|
||||
"hints.threads.see_more": "צפיה בעוד תגובות משרת {domain}",
|
||||
"home.column_settings.show_quotes": "הצגת ציטוטים",
|
||||
"home.column_settings.show_reblogs": "הצגת הדהודים",
|
||||
"home.column_settings.show_replies": "הצגת תגובות",
|
||||
"home.hide_announcements": "הסתר הכרזות",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue