Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-06-12 10:17:21 +09:00
commit f3c3ea42c2
301 changed files with 6618 additions and 3070 deletions

View file

@ -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
View 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'

View file

@ -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
View file

@ -78,3 +78,6 @@ docker-compose.override.yml
# Ignore local-only rspec configuration
.rspec-local
*storybook.log
storybook-static

View file

@ -1,3 +1,6 @@
---
Naming/BlockForwarding:
EnforcedStyle: explicit
Naming/PredicateMethod:
Enabled: false

View file

@ -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
View 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
View file

@ -0,0 +1,7 @@
import { addons } from 'storybook/manager-api';
import theme from './storybook-theme';
addons.setConfig({
theme,
});

View 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
View 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;

View 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 {};

View 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',
});

View 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]);

View file

@ -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 users 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 posts 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 Mastodons 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 authors 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

View file

@ -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
View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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?
[]

View file

@ -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));
});

View 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');

View file

@ -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));

View file

@ -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>
);
};

View file

@ -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,
})}

View 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,
};

View file

@ -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,

View file

@ -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>

View file

@ -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}}'

View file

@ -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 && (
<>
&middot;
<div className='hover-card__familiar-followers'>
@ -102,6 +115,22 @@ export const HoverCardAccount = forwardRef<
</div>
</>
)}
{(isMutual || isFollower) && (
<>
&middot;
{isMutual ? (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
) : (
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
)}
</>
)}
</div>
<FollowButton accountId={accountId} />

View file

@ -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}

View file

@ -7,7 +7,7 @@ interface Props {
id: string;
icon: IconProp;
count: number;
issueBadge: boolean;
issueBadge?: boolean;
className: string;
}
export const IconWithBadge: React.FC<Props> = ({

View file

@ -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 (

View file

@ -624,11 +624,11 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{children}
{media}
{hashtagBar}
{emojiReactionsBar}
{children}
</>
)}

View file

@ -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;
});
},

View file

@ -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);

View file

@ -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}
/>
);
};

View file

@ -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);

View file

@ -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>' && (

View file

@ -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,

View file

@ -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

View file

@ -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) =>
(

View file

@ -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'

View file

@ -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} />;
};

View file

@ -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>
);
};

View file

@ -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'>

View file

@ -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;

View file

@ -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));

View 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;

View file

@ -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']),
};

View 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>
);
};

View file

@ -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 />}

View file

@ -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}

View file

@ -270,7 +270,6 @@ const ReactionsBar = ({
leave: {
scale: 0,
},
immediate: reduceMotion,
keys: visibleReactions.map(x => x.get('name')),
});

View file

@ -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';

View file

@ -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}

View file

@ -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))));

View file

@ -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>

View file

@ -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')} />
)}
</>
)}

View file

@ -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;

View 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;
}
};

View file

@ -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>
);
}

View file

@ -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));

View file

@ -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>
);
};

View file

@ -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))));

View file

@ -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>
);
};

View 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>
</>
);
};

View file

@ -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;

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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));

View file

@ -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>
);
};

View file

@ -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 (

View file

@ -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;
});
});

View 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;
}

View file

@ -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 />}

View file

@ -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);

View 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,
};
};

View file

@ -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;
};

View 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,
};
};

View 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;
}

View file

@ -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ù",

View file

@ -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",

View file

@ -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í",

View file

@ -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…",

View file

@ -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",

View file

@ -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",

View file

@ -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 friends 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 friends 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"
}

View file

@ -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}",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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"
}

View file

@ -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",

View file

@ -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",

View file

@ -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.",

View file

@ -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 dhinntrig 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": "Dhfhalbh 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",

View file

@ -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",

View file

@ -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