Merge remote-tracking branch 'glitch/main'
ci/woodpecker/push/woodpecker Pipeline was successful Details

pull/53/head
kouhai dev 2023-01-29 21:27:42 -08:00
commit c24d63d33c
528 changed files with 10339 additions and 4362 deletions

View File

@ -15,6 +15,12 @@
"webben.browserslist" "webben.browserslist"
], ],
"features": {
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or the host. // This can be used to network with other containers or the host.
"forwardPorts": [3000, 4000], "forwardPorts": [3000, 4000],

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: hadolint/hadolint-action@v3.0.0 - uses: hadolint/hadolint-action@v3.1.0
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2 - uses: docker/login-action@v2

38
.github/workflows/lint-json.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: JSON Linting
on:
push:
branches-ignore:
- 'dependabot/**'
paths:
- 'package.json'
- 'yarn.lock'
- '.prettier*'
- '**/*.json'
- '.github/workflows/lint-json.yml'
pull_request:
paths:
- 'package.json'
- 'yarn.lock'
- '.prettier*'
- '**/*.json'
- '.github/workflows/lint-json.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Prettier
run: yarn prettier --check "**/*.json"

40
.github/workflows/lint-yml.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: YML Linting
on:
push:
branches-ignore:
- 'dependabot/**'
paths:
- 'package.json'
- 'yarn.lock'
- '.prettier*'
- '**/*.yaml'
- '**/*.yml'
- '.github/workflows/lint-yml.yml'
pull_request:
paths:
- 'package.json'
- 'yarn.lock'
- '.prettier*'
- '**/*.yaml'
- '**/*.yml'
- '.github/workflows/lint-yml.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Prettier
run: yarn prettier --check "**/*.{yml,yaml}"

View File

@ -57,8 +57,6 @@ jobs:
cache: yarn cache: yarn
- name: Install dependencies - name: Install dependencies
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Check prettier formatting
run: yarn format-check
- name: Set-up RuboCop Problem Mathcher - name: Set-up RuboCop Problem Mathcher
uses: r7kamura/rubocop-problem-matchers-action@v1 uses: r7kamura/rubocop-problem-matchers-action@v1
- name: Set-up Stylelint Problem Matcher - name: Set-up Stylelint Problem Matcher

View File

@ -70,3 +70,10 @@ docker-compose.override.yml
# Ignore locale files # Ignore locale files
/app/javascript/mastodon/locales /app/javascript/mastodon/locales
/config/locales /config/locales
# Ignore glitch-soc locale files
/app/javascript/flavours/glitch/locales
/config/locales-glitch
# Ignore glitch-soc emoji map file
/app/javascript/flavours/glitch/features/emoji/emoji_map.json

View File

@ -3,6 +3,191 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.1.0] - UNRELEASED
### Added
- **Add support for importing/exporting server-wide domain blocks** ([enbylenore](https://github.com/mastodon/mastodon/pull/20597), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21471), [dariusk](https://github.com/mastodon/mastodon/pull/22803), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21470))
- Add listing of followed hashtags ([connorshea](https://github.com/mastodon/mastodon/pull/21773))
- Add support for editing media description and focus point of already-sent posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20878))
- Add follow request banner on account header ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20785))
- Add confirmation screen when handling reports ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22375), [Gargron](https://github.com/mastodon/mastodon/pull/23156), [tribela](https://github.com/mastodon/mastodon/pull/23178))
- Add option to make the landing page be `/about` even when trends are enabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20808))
- Add `noindex` setting back to the admin interface ([prplecake](https://github.com/mastodon/mastodon/pull/22205))
- Add instance peers API endpoint toggle back to the admin interface ([dariusk](https://github.com/mastodon/mastodon/pull/22810))
- Add instance activity API endpoint toggle back to the admin interface ([dariusk](https://github.com/mastodon/mastodon/pull/22833))
- Add `account.approved` webhook ([Saiv46](https://github.com/mastodon/mastodon/pull/22938))
- Add 12 hours option to polls ([Pleclown](https://github.com/mastodon/mastodon/pull/21131))
- Add dropdown menu item to open admin interface for remote domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21895))
- Add `--remove-headers`, `--prune-profiles` and `--include-follows` flags to `tootctl media remove` ([evanphilip](https://github.com/mastodon/mastodon/pull/22149))
- Add `--email` and `--dry-run` options to `tootctl accounts delete` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22328))
- Add `tootctl accounts migrate` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22330))
- Add `tootctl accounts prune` ([tribela](https://github.com/mastodon/mastodon/pull/18397))
- Add `tootctl domains purge` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22063))
- Add `SIDEKIQ_CONCURRENCY` environment variable ([muffinista](https://github.com/mastodon/mastodon/pull/19589))
- Add `MIN_THREADS` environment variable to set minimum Puma threads ([jimeh](https://github.com/mastodon/mastodon/pull/21048))
- Add explanation text to log-in page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20946))
- Add user profile OpenGraph tag on post pages ([bramus](https://github.com/mastodon/mastodon/pull/21423))
- Add maskable icon support for Android ([workeffortwaste](https://github.com/mastodon/mastodon/pull/20904))
- Add Belarusian to supported languages ([Mixaill](https://github.com/mastodon/mastodon/pull/22022))
- Add Western Frisian to supported languages ([ykzts](https://github.com/mastodon/mastodon/pull/18602))
- Add Montenegrin to the language picker ([ayefries](https://github.com/mastodon/mastodon/pull/21013))
- Add Southern Sami and Lule Sami to the language picker ([Jullan-M](https://github.com/mastodon/mastodon/pull/21262))
- Add logging for Rails cache timeouts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21667))
- Add color highlight for active hashtag “follow” button ([MFTabriz](https://github.com/mastodon/mastodon/pull/21629))
- Add brotli compression to `assets:precompile` ([Izorkin](https://github.com/mastodon/mastodon/pull/19025))
- Add “disabled” account filter to the `/admin/accounts` UI ([tribela](https://github.com/mastodon/mastodon/pull/21282))
- Add transparency to modal background for accessibility ([edent](https://github.com/mastodon/mastodon/pull/18081))
- Add `title` attribute to video elements in media attachments ([bramus](https://github.com/mastodon/mastodon/pull/21420))
- Add left and right margins to emojis ([dsblank](https://github.com/mastodon/mastodon/pull/20464))
- Add `reading:autoplay:gifs` to `/api/v1/preferences` ([j-f1](https://github.com/mastodon/mastodon/pull/22706))
- Add `hide_collections` parameter to `/api/v1/accounts/credentials` ([CarlSchwan](https://github.com/mastodon/mastodon/pull/22790))
- Add `policy` attribute to web push subscription objects in `/api/v1/push/subscriptions` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23210))
- Add more specific error messages to HTTP signature verification ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21617))
- Add Storj DCS to cloud object storage options in the `mastodon:setup` rake task ([jtolio](https://github.com/mastodon/mastodon/pull/21929))
- Add checkmark symbol in the checkbox for sensitive media ([sidp](https://github.com/mastodon/mastodon/pull/22795))
- Add missing accessibility attributes to logout link in modals ([kytta](https://github.com/mastodon/mastodon/pull/22549))
- Add missing accessibility attributes to “Hide image” button in `MediaGallery` ([hs4man21](https://github.com/mastodon/mastodon/pull/22513))
- Add missing accessibility attributes to hide content warning field when disabled ([hs4man21](https://github.com/mastodon/mastodon/pull/22568))
- Add `aria-hidden` to footer circle dividers to improve accessibility ([hs4man21](https://github.com/mastodon/mastodon/pull/22576))
- Add `lang` attribute to compose form inputs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23240))
### Changed
- **Ensure exact match is the first result in hashtag searches** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21315))
- Change account search to return followed accounts first ([dariusk](https://github.com/mastodon/mastodon/pull/22956))
- Change batch account suspension to create a strike ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20897))
- Change default reply language to match the default language when replying to a translated post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22272))
- Change misleading wording about waitlists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20850))
- Increase width of the unread notification border ([connorshea](https://github.com/mastodon/mastodon/pull/21692))
- Change new post notification button on profiles to make it more apparent when it is enabled ([tribela](https://github.com/mastodon/mastodon/pull/22541))
- Change trending tags admin interface to always show batch action controls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23013))
- Change wording of some OAuth scope descriptions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22491))
- Change wording of admin report handling actions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18388))
- Change confirm prompts for relationships management ([tribela](https://github.com/mastodon/mastodon/pull/19411))
- Change language surrounding disability in prompts for media descriptions ([hs4man21](https://github.com/mastodon/mastodon/pull/20923))
- Change confusing wording in the sign in banner ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22490))
- Change account moderation notes to make links clickable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22553))
- Change email address input to be read-only for logged-in users when requesting a new confirmation e-mail ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23247))
- Save avatar or header correctly even if the other one fails ([tribela](https://github.com/mastodon/mastodon/pull/18465))
- Change `referrer-policy` to `same-origin` application-wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23014), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23037))
- Add 'private' to `Cache-Control`, match Rails expectations ([daxtens](https://github.com/mastodon/mastodon/pull/20608))
- Make the button that expands the compose form differentiable from the button that publishes a post ([Tak](https://github.com/mastodon/mastodon/pull/20864))
- Change automatic post deletion configuration to be accessible to moved users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20774))
- Make tag following idempotent ([trwnh](https://github.com/mastodon/mastodon/pull/20860), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21285))
- Use buildx functions for faster builds ([inductor](https://github.com/mastodon/mastodon/pull/20692))
- Split off Dockerfile components for faster builds ([moritzheiber](https://github.com/mastodon/mastodon/pull/20933), [ineffyble](https://github.com/mastodon/mastodon/pull/20948), [BtbN](https://github.com/mastodon/mastodon/pull/21028))
- Change last occurrence of “silence” to “limit” in UI text ([cincodenada](https://github.com/mastodon/mastodon/pull/20637))
- Change “hide toot” to “hide post” ([seanthegeek](https://github.com/mastodon/mastodon/pull/22385))
- Don't allow URLs that contain non-normalized paths to be verified ([dgl](https://github.com/mastodon/mastodon/pull/20999))
- Change the “Trending now” header to be a link to the Explore page ([connorshea](https://github.com/mastodon/mastodon/pull/21759))
- Change PostgreSQL connection timeout from 2 minutes to 15 seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21790))
- Make handle more easily selectable on profile page ([cadars](https://github.com/mastodon/mastodon/pull/21479))
- Allow admins to refresh remotely-suspended accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22327))
- Change dropdown menu to contain “Copy link to post” even for non-public posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21316))
- Allow adding relays in secure mode and limited federation mode ([ineffyble](https://github.com/mastodon/mastodon/pull/22324))
- Change timestamps to be displayed using the user's timezone throughout the moderation interface ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/21878), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22555))
- Change CSP directives on API to be tight and concise ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20960))
- Change web UI to not autofocus the compose form ([raboof](https://github.com/mastodon/mastodon/pull/16517))
- Change idempotency key handling for posting when database access is slow ([lambda](https://github.com/mastodon/mastodon/pull/21840))
- Change remote media files to be downloaded outside of transactions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21796))
- Improve contrast of charts in “poll has ended” notifications ([j-f1](https://github.com/mastodon/mastodon/pull/22575))
- Change OEmbed detection and validation to be somewhat more lenient ([ineffyble](https://github.com/mastodon/mastodon/pull/22533))
- Widen ElasticSearch version detection to not display a warning for OpenSearch ([VyrCossont](https://github.com/mastodon/mastodon/pull/22422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23064))
- Change link verification to allow pages larger than 1MB as long as the link is in the first 1MB ([untitaker](https://github.com/mastodon/mastodon/pull/22879))
- Update default Node.js version to Node.js 16 ([ineffyble](https://github.com/mastodon/mastodon/pull/22223), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22342))
### Removed
- Officially remove support for Ruby 2.6 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21477))
- Remove `object-fit` polyfill used for old versions of Microsoft Edge ([shuuji3](https://github.com/mastodon/mastodon/pull/22693))
- Remove empty `title` tag from mailer layout ([nametoolong](https://github.com/mastodon/mastodon/pull/23078))
### Fixed
- **Fix changing domain block severity not undoing individual account effects** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22135))
- Fix suspension worker crashing on S3-compatible setups without ACL support ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22487))
- Fix possible race conditions when suspending/unsuspending accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22363))
- Fix being stuck in edit mode when deleting the edited status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22126))
- Fix filters not being applied to some notification types ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23211))
- Fix some performance issues with `/admin/instances` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21907))
- Fix some pre-4.0 admin audit logs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22091))
- Fix moderation audit log items for warnings having incorrect links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23242))
- Fix account activation being sometimes triggered before email confirmation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23245))
- Fix missing OAuth scopes for admin APIs ([trwnh](https://github.com/mastodon/mastodon/pull/20918), [trwnh](https://github.com/mastodon/mastodon/pull/20979))
- Fix voter count not being cleared when a poll is reset ([afontenot](https://github.com/mastodon/mastodon/pull/21700))
- Fix attachments of edited statuses not being fetched ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21565))
- Fix irreversible and whole_word parameters handling in `/api/v1/filters` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21988))
- Fix 500 error when marking posts as sensitive while some of them are deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22134))
- Fix expanded statuses not always being scrolled into view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21797))
- Fix not being able to scroll the remote interaction modal on small screens ([xendke](https://github.com/mastodon/mastodon/pull/21763))
- Fix audio player volume control on Safari ([minacle](https://github.com/mastodon/mastodon/pull/23187))
- Fix disappearing “Explore” tabs on Safari ([nyura](https://github.com/mastodon/mastodon/pull/20917), [ykzts](https://github.com/mastodon/mastodon/pull/20982))
- Fix wrong padding in RTL layout ([Gargron](https://github.com/mastodon/mastodon/pull/23157))
- Fix drag & drop upload area display in single-column mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23217))
- Fix being unable to get a single EmailDomainBlock from the admin API ([trwnh](https://github.com/mastodon/mastodon/pull/20846))
- Fix pagination of followed tags ([trwnh](https://github.com/mastodon/mastodon/pull/20861))
- Fix dropdown menu positions when scrolling ([sidp](https://github.com/mastodon/mastodon/pull/22916), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23062))
- Fix email with empty domain name labels passing validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23246))
- Fix mysterious registration failure when “Require a reason to join” is set with open registrations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22127))
- Fix attachment rendering of edited posts in OpenGraph ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22270))
- Fix invalid/empty RSS feed link on account pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20772))
- Fix error in `VerifyLinkService` when processing links with no href ([joshuap](https://github.com/mastodon/mastodon/pull/20741))
- Fix error in `VerifyLinkService` when processing links with invalid URLs ([untitaker](https://github.com/mastodon/mastodon/pull/23204))
- Fix media uploads with FFmpeg 5 ([dead10ck](https://github.com/mastodon/mastodon/pull/21191))
- Fix sensitive flag not being set when replying to a post with a content warning under certain conditions ([kedamaDQ](https://github.com/mastodon/mastodon/pull/21724))
- Fix “Share @user's profile” profile menu item not working ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21490))
- Fix crash and incorrect behavior in `tootctl domains crawl` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19004))
- Fix autoplay on iOS ([jamesadney](https://github.com/mastodon/mastodon/pull/21422))
- Fix spaces not being stripped in admin account search ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21324))
- Fix spaces not being stripped when adding relays ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22655))
- Fix infinite loading spinner instead of soft 404 for non-existing remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21303))
- Fix minor visual issue with the top border of verified account fields ([j-f1](https://github.com/mastodon/mastodon/pull/22006))
- Fix pending account approval and rejection not being recorded in the admin audit log ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/22088))
- Fix “Sign up” button with closed registrations not opening modal on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22060))
- Fix UI header overflowing on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21783))
- Fix 500 error when trying to migrate to an invalid address ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21462))
- Fix crash when trying to fetch unobtainable avatar of user using external authentication ([lochiiconnectivity](https://github.com/mastodon/mastodon/pull/22462))
- Fix potential duplicate statuses in Explore tab ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22121))
- Fix deprecation warning in `tootctl accounts rotate` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22120))
- Fix missing style in warning and strike cards ([AtelierSnek](https://github.com/mastodon/mastodon/pull/22177), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22302))
- Fix wasteful request to `/api/v1/custom_emojis` when not logged in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22326))
- Fix replies sometimes being delivered to user-blocked domains ([tribela](https://github.com/mastodon/mastodon/pull/22117))
- Fix admin dashboard crash when using some ElasticSearch replacements ([cortices](https://github.com/mastodon/mastodon/pull/21006))
- Fix profile avatar being slightly offset into left border ([RiedleroD](https://github.com/mastodon/mastodon/pull/20994))
- Fix N+1 queries in `NotificationsController` ([nametoolong](https://github.com/mastodon/mastodon/pull/21202))
- Fix being unable to react to announcements with the keycap number sign emoji ([kescherCode](https://github.com/mastodon/mastodon/pull/22231))
- Fix height computation of post embeds ([hodgesmr](https://github.com/mastodon/mastodon/pull/22141))
- Fix accessibility issue of the search bar due to hidden placeholder ([alexstine](https://github.com/mastodon/mastodon/pull/21275))
- Fix layout change handler not being removed due to a typo ([nschonni](https://github.com/mastodon/mastodon/pull/21829))
- Fix typo in the default `S3_HOSTNAME` used in the `mastodon:setup` rake task ([danp](https://github.com/mastodon/mastodon/pull/19932))
- Fix the top action bar appearing in the multi-column layout ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20943))
- Fix inability to use local LibreTranslate without setting `ALLOWED_PRIVATE_ADDRESSES` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21926))
- Fix punycoded local domains not being prettified in initial state ([Tritlo](https://github.com/mastodon/mastodon/pull/21440))
- Fix CSP violation warning by removing inline CSS from SVG logo ([luxiaba](https://github.com/mastodon/mastodon/pull/20814))
- Fix margin for search field on medium window size ([minacle](https://github.com/mastodon/mastodon/pull/21606))
- Fix search popout scrolling with the page in single-column mode ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/16463))
- Fix minor status cache hydration discrepancy ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19879))
- Fix `・` detection in hashtags ([parthoghosh24](https://github.com/mastodon/mastodon/pull/22888))
- Fix hashtag follows bypassing user blocks ([tribela](https://github.com/mastodon/mastodon/pull/22849))
- Fix moved accounts being incorrectly redirected to account settings when trying to view a remote profile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22497))
- Fix site upload validations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22479))
- Fix “Add new domain block” button using last submitted search value instead of the current one ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22485))
- Fix misleading hashtag warning when posting with “Followers only” or “Mentioned people only” visibility ([n0toose](https://github.com/mastodon/mastodon/pull/22827))
- Fix embedded posts with videos grabbing focus ([Akkiesoft](https://github.com/mastodon/mastodon/pull/22778))
- Fix `$` not being escaped in `.env.production` files generated by the `mastodon:setup` rake task ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23012), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23072))
- Fix sanitizer parsing link text as HTML when stripping unsupported links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22558))
- Fix `scheduled_at` input not using `datetime-local` when editing announcements ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21896))
- Fix REST API serializer for `Account` not including `moved` when the moved account has itself moved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22483))
- Fix `/api/v1/admin/trends/tags` using wrong serializer ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18943))
- Fix situations in which instance actor can be set to a Mastodon-incompatible name ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22307))
### Security
- Add `form-action` CSP directive ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20781), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20958), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20962))
- Fix unbounded recursion in account discovery ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22025))
- Revoke all authorized applications on password reset ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/21325))
## [4.0.2] - 2022-11-15 ## [4.0.2] - 2022-11-15
### Fixed ### Fixed

13
Gemfile
View File

@ -10,12 +10,12 @@ gem 'puma', '~> 5.6'
gem 'rails', '~> 6.1.7' gem 'rails', '~> 6.1.7'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
gem 'rack', '~> 2.2.4' gem 'rack', '~> 2.2.6'
gem 'hamlit-rails', '~> 0.2' gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.4' gem 'pg', '~> 1.4'
gem 'makara', '~> 0.5' gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8' gem 'pghero'
gem 'dotenv-rails', '~> 2.8' gem 'dotenv-rails', '~> 2.8'
gem 'aws-sdk-s3', '~> 1.117', require: false gem 'aws-sdk-s3', '~> 1.117', require: false
@ -51,7 +51,7 @@ gem 'ed25519', '~> 1.3'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.9' gem 'redis-namespace', '~> 1.10'
gem 'htmlentities', '~> 4.3' gem 'htmlentities', '~> 4.3'
gem 'http', '~> 5.1' gem 'http', '~> 5.1'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
@ -60,7 +60,7 @@ gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar' gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.13' gem 'nokogiri', '~> 1.14'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.13' gem 'oj', '~> 3.13'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
@ -120,14 +120,13 @@ end
group :test do group :test do
gem 'capybara', '~> 3.38' gem 'capybara', '~> 3.38'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 3.0' gem 'faker', '~> 3.1'
gem 'json-schema', '~> 3.0' gem 'json-schema', '~> 3.0'
gem 'microformats', '~> 4.4'
gem 'rack-test', '~> 2.0' gem 'rack-test', '~> 2.0'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec_junit_formatter', '~> 0.6' gem 'rspec_junit_formatter', '~> 0.6'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false gem 'simplecov', '~> 0.22', require: false
gem 'webmock', '~> 3.18' gem 'webmock', '~> 3.18'
end end

View File

@ -10,40 +10,40 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.7) actioncable (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.7) actionmailbox (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
activejob (= 6.1.7) activejob (= 6.1.7.1)
activerecord (= 6.1.7) activerecord (= 6.1.7.1)
activestorage (= 6.1.7) activestorage (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.7) actionmailer (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
actionview (= 6.1.7) actionview (= 6.1.7.1)
activejob (= 6.1.7) activejob (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.7) actionpack (6.1.7.1)
actionview (= 6.1.7) actionview (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7) actiontext (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
activerecord (= 6.1.7) activerecord (= 6.1.7.1)
activestorage (= 6.1.7) activestorage (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.7) actionview (6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -54,22 +54,22 @@ GEM
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.7) activejob (6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.7) activemodel (6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
activerecord (6.1.7) activerecord (6.1.7.1)
activemodel (= 6.1.7) activemodel (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
activestorage (6.1.7) activestorage (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
activejob (= 6.1.7) activejob (= 6.1.7.1)
activerecord (= 6.1.7) activerecord (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.7) activesupport (6.1.7.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -130,7 +130,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6) redis (>= 1.0, < 6)
builder (3.2.4) builder (3.2.4)
bullet (7.0.4) bullet (7.0.7)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundler-audit (0.9.1) bundler-audit (0.9.1)
@ -174,7 +174,7 @@ GEM
cocoon (1.2.15) cocoon (1.2.15)
coderay (1.1.3) coderay (1.1.3)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.1.10) concurrent-ruby (1.2.0)
connection_pool (2.3.0) connection_pool (2.3.0)
cose (1.2.1) cose (1.2.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
@ -184,6 +184,7 @@ GEM
crass (1.0.6) crass (1.0.6)
css_parser (1.12.0) css_parser (1.12.0)
addressable addressable
date (3.3.3)
debug_inspector (1.0.0) debug_inspector (1.0.0)
devise (4.8.1) devise (4.8.1)
bcrypt (~> 3.0) bcrypt (~> 3.0)
@ -203,7 +204,7 @@ GEM
diff-lcs (1.5.0) diff-lcs (1.5.0)
discard (1.2.1) discard (1.2.1)
activerecord (>= 4.2, < 8) activerecord (>= 4.2, < 8)
docile (1.3.4) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.6.2) doorkeeper (5.6.2)
@ -223,12 +224,12 @@ GEM
faraday (~> 1) faraday (~> 1)
multi_json multi_json
encryptor (3.0.0) encryptor (3.0.0)
erubi (1.11.0) erubi (1.12.0)
et-orbi (1.2.7) et-orbi (1.2.7)
tzinfo tzinfo
excon (0.95.0) excon (0.95.0)
fabrication (2.30.0) fabrication (2.30.0)
faker (3.0.0) faker (3.1.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.9.3) faraday (1.9.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@ -282,7 +283,7 @@ GEM
addressable (~> 2.7) addressable (~> 2.7)
omniauth (>= 1.9, < 3) omniauth (>= 1.9, < 3)
openid_connect (~> 1.2) openid_connect (~> 1.2)
globalid (1.0.0) globalid (1.0.1)
activesupport (>= 5.0) activesupport (>= 5.0)
hamlit (2.13.0) hamlit (2.13.0)
temple (>= 0.8.2) temple (>= 0.8.2)
@ -330,9 +331,9 @@ GEM
idn-ruby (0.1.5) idn-ruby (0.1.5)
ipaddress (0.8.3) ipaddress (0.8.3)
jmespath (1.6.2) jmespath (1.6.2)
json (2.6.2) json (2.6.3)
json-canonicalization (0.3.0) json-canonicalization (0.3.0)
json-jwt (1.13.0) json-jwt (1.14.0)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
bindata bindata
@ -349,7 +350,7 @@ GEM
json-schema (3.0.0) json-schema (3.0.0)
addressable (>= 2.8) addressable (>= 2.8)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.4.1) jwt (2.5.0)
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@ -389,8 +390,11 @@ GEM
loofah (2.19.1) loofah (2.19.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.8.0.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
makara (0.5.1) makara (0.5.1)
activerecord (>= 5.2.0) activerecord (>= 5.2.0)
marcel (1.0.2) marcel (1.0.2)
@ -399,19 +403,21 @@ GEM
matrix (0.4.2) matrix (0.4.2)
memory_profiler (1.0.1) memory_profiler (1.0.1)
method_source (1.0.0) method_source (1.0.0)
microformats (4.4.1)
json (~> 2.2)
nokogiri (~> 1.10)
mime-types (3.4.1) mime-types (3.4.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105) mime-types-data (3.2022.0105)
mini_mime (1.1.2) mini_mime (1.1.2)
mini_portile2 (2.8.0) mini_portile2 (2.8.1)
minitest (5.16.3) minitest (5.17.0)
msgpack (1.6.0) msgpack (1.6.0)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-imap (0.3.4)
date
net-protocol
net-ldap (0.17.1) net-ldap (0.17.1)
net-pop (0.1.2)
net-protocol
net-protocol (0.1.3) net-protocol (0.1.3)
timeout timeout
net-scp (4.0.0.rc1) net-scp (4.0.0.rc1)
@ -420,7 +426,7 @@ GEM
net-protocol net-protocol
net-ssh (7.0.1) net-ssh (7.0.1)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.10) nokogiri (1.14.0)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nsa (0.2.8) nsa (0.2.8)
@ -456,16 +462,16 @@ GEM
openssl-signature_algorithm (1.2.1) openssl-signature_algorithm (1.2.1)
openssl (> 2.0, < 3.1) openssl (> 2.0, < 3.1)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.14.11) ox (2.14.13)
parallel (1.22.1) parallel (1.22.1)
parser (3.1.2.1) parser (3.2.0.0)
ast (~> 2.4.1) ast (~> 2.4.1)
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.4.5) pg (1.4.5)
pghero (2.8.3) pghero (3.1.0)
activerecord (>= 5) activerecord (>= 6)
pkg-config (1.5.1) pkg-config (1.5.1)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.18.0) premailer (1.18.0)
@ -491,8 +497,8 @@ GEM
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.1) racc (1.6.2)
rack (2.2.4) rack (2.2.6.2)
rack-attack (6.6.1) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
@ -507,20 +513,20 @@ GEM
rack rack
rack-test (2.0.2) rack-test (2.0.2)
rack (>= 1.3) rack (>= 1.3)
rails (6.1.7) rails (6.1.7.1)
actioncable (= 6.1.7) actioncable (= 6.1.7.1)
actionmailbox (= 6.1.7) actionmailbox (= 6.1.7.1)
actionmailer (= 6.1.7) actionmailer (= 6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
actiontext (= 6.1.7) actiontext (= 6.1.7.1)
actionview (= 6.1.7) actionview (= 6.1.7.1)
activejob (= 6.1.7) activejob (= 6.1.7.1)
activemodel (= 6.1.7) activemodel (= 6.1.7.1)
activerecord (= 6.1.7) activerecord (= 6.1.7.1)
activestorage (= 6.1.7) activestorage (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.7) railties (= 6.1.7.1)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
@ -536,9 +542,9 @@ GEM
railties (>= 6.0.0, < 7) railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (6.1.7) railties (6.1.7.1)
actionpack (= 6.1.7) actionpack (= 6.1.7.1)
activesupport (= 6.1.7) activesupport (= 6.1.7.1)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@ -550,11 +556,11 @@ GEM
rdf (~> 3.2) rdf (~> 3.2)
redcarpet (3.5.1) redcarpet (3.5.1)
redis (4.5.1) redis (4.5.1)
redis-namespace (1.9.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.6.0) regexp_parser (2.6.2)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
@ -589,27 +595,30 @@ GEM
rspec-support (3.11.1) rspec-support (3.11.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.39.0) rubocop (1.44.0)
json (~> 2.3) json (~> 2.3)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.1.2.1) parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.23.0, < 2.0) rubocop-ast (>= 1.24.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.23.0) rubocop-ast (1.24.1)
parser (>= 3.1.1.0) parser (>= 3.1.1.0)
rubocop-performance (1.15.1) rubocop-capybara (2.17.0)
rubocop (~> 1.41)
rubocop-performance (1.15.2)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.17.2) rubocop-rails (2.17.4)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.15.0) rubocop-rspec (2.18.1)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-saml (1.13.0) ruby-saml (1.13.0)
nokogiri (>= 1.10.5) nokogiri (>= 1.10.5)
@ -648,12 +657,12 @@ GEM
simple_form (5.1.0) simple_form (5.1.0)
actionpack (>= 5.2) actionpack (>= 5.2)
activemodel (>= 5.2) activemodel (>= 5.2)
simplecov (0.21.2) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.2) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sprockets (3.7.2) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@ -707,7 +716,7 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (2.3.0) unicode-display_width (2.4.2)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
validate_email (0.1.6) validate_email (0.1.6)
activemodel (>= 3.0) activemodel (>= 3.0)
@ -784,7 +793,7 @@ DEPENDENCIES
dotenv-rails (~> 2.8) dotenv-rails (~> 2.8)
ed25519 (~> 1.3) ed25519 (~> 1.3)
fabrication (~> 2.30) fabrication (~> 2.30)
faker (~> 3.0) faker (~> 3.1)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage fastimage
fog-core (<= 2.4.0) fog-core (<= 2.4.0)
@ -812,10 +821,9 @@ DEPENDENCIES
makara (~> 0.5) makara (~> 0.5)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
memory_profiler memory_profiler
microformats (~> 4.4)
mime-types (~> 3.4.1) mime-types (~> 3.4.1)
net-ldap (~> 0.17) net-ldap (~> 0.17)
nokogiri (~> 1.13) nokogiri (~> 1.14)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.13) oj (~> 3.13)
omniauth (~> 1.9) omniauth (~> 1.9)
@ -825,7 +833,7 @@ DEPENDENCIES
ox (~> 2.14) ox (~> 2.14)
parslet parslet
pg (~> 1.4) pg (~> 1.4)
pghero (~> 2.8) pghero
pkg-config (~> 1.5) pkg-config (~> 1.5)
posix-spawn posix-spawn
premailer-rails premailer-rails
@ -835,7 +843,7 @@ DEPENDENCIES
public_suffix (~> 5.0) public_suffix (~> 5.0)
puma (~> 5.6) puma (~> 5.6)
pundit (~> 2.3) pundit (~> 2.3)
rack (~> 2.2.4) rack (~> 2.2.6)
rack-attack (~> 6.6) rack-attack (~> 6.6)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rack-test (~> 2.0) rack-test (~> 2.0)
@ -846,7 +854,7 @@ DEPENDENCIES
rdf-normalize (~> 0.5) rdf-normalize (~> 0.5)
redcarpet (~> 3.5) redcarpet (~> 3.5)
redis (~> 4.5) redis (~> 4.5)
redis-namespace (~> 1.9) redis-namespace (~> 1.10)
rexml (~> 3.2) rexml (~> 3.2)
rqrcode (~> 2.1) rqrcode (~> 2.1)
rspec-rails (~> 5.1) rspec-rails (~> 5.1)
@ -865,7 +873,7 @@ DEPENDENCIES
sidekiq-unique-jobs (~> 7.1) sidekiq-unique-jobs (~> 7.1)
simple-navigation (~> 4.4) simple-navigation (~> 4.4)
simple_form (~> 5.1) simple_form (~> 5.1)
simplecov (~> 0.21) simplecov (~> 0.22)
sprockets (~> 3.7.2) sprockets (~> 3.7.2)
sprockets-rails (~> 3.4) sprockets-rails (~> 3.4)
stackprof stackprof
@ -880,9 +888,3 @@ DEPENDENCIES
webpacker (~> 5.4) webpacker (~> 5.4)
webpush! webpush!
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.2.33

View File

@ -21,7 +21,7 @@ module Admin
account_action.save! account_action.save!
if account_action.with_report? if account_action.with_report?
redirect_to admin_reports_path redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
else else
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id)
end end

View File

@ -23,9 +23,7 @@ module Admin
@import = Admin::Import.new(import_params) @import = Admin::Import.new(import_params)
return render :new unless @import.validate return render :new unless @import.validate
parse_import_data!(export_headers) @import.csv_rows.each do |row|
@data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
domain = row['#domain'].strip domain = row['#domain'].strip
next if DomainAllow.allowed?(domain) next if DomainAllow.allowed?(domain)

View File

@ -23,24 +23,30 @@ module Admin
@import = Admin::Import.new(import_params) @import = Admin::Import.new(import_params)
return render :new unless @import.validate return render :new unless @import.validate
parse_import_data!(export_headers)
@global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc)) @global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
@form = Form::DomainBlockBatch.new @form = Form::DomainBlockBatch.new
@domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row| @domain_blocks = @import.csv_rows.filter_map do |row|
domain = row['#domain'].strip domain = row['#domain'].strip
next if DomainBlock.rule_for(domain).present? next if DomainBlock.rule_for(domain).present?
domain_block = DomainBlock.new(domain: domain, domain_block = DomainBlock.new(domain: domain,
severity: row['#severity'].strip, severity: row.fetch('#severity', :suspend),
reject_media: row['#reject_media'].strip, reject_media: row.fetch('#reject_media', false),
reject_reports: row['#reject_reports'].strip, reject_reports: row.fetch('#reject_reports', false),
private_comment: @global_private_comment, private_comment: @global_private_comment,
public_comment: row['#public_comment']&.strip, public_comment: row['#public_comment'],
obfuscate: row['#obfuscate'].strip) obfuscate: row.fetch('#obfuscate', false))
domain_block if domain_block.valid? if domain_block.invalid?
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', '))
next
end
domain_block
rescue ArgumentError => e
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message)
next
end end
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)

View File

@ -49,7 +49,7 @@ module Admin
private private
def set_instance def set_instance
@instance = Instance.find(params[:id]) @instance = Instance.find(TagManager.instance.normalize_domain(params[:id]&.strip))
end end
def set_instances def set_instances

View File

@ -3,6 +3,11 @@
class Admin::Reports::ActionsController < Admin::BaseController class Admin::Reports::ActionsController < Admin::BaseController
before_action :set_report before_action :set_report
def preview
authorize @report, :show?
@moderation_action = action_from_button
end
def create def create
authorize @report, :show? authorize @report, :show?
@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
status_ids: @report.status_ids, status_ids: @report.status_ids,
current_account: current_account, current_account: current_account,
report_id: @report.id, report_id: @report.id,
send_email_notification: !@report.spam? send_email_notification: !@report.spam?,
text: params[:text]
) )
status_batch_action.save! status_batch_action.save!
@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
report_id: @report.id, report_id: @report.id,
target_account: @report.target_account, target_account: @report.target_account,
current_account: current_account, current_account: current_account,
send_email_notification: !@report.spam? send_email_notification: !@report.spam?,
text: params[:text]
) )
account_action.save! account_action.save!
else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end end
redirect_to admin_reports_path redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
end end
private private
@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
'silence' 'silence'
elsif params[:suspend] elsif params[:suspend]
'suspend' 'suspend'
elsif params[:moderation_action]
params[:moderation_action]
end end
end end
end end

View File

@ -21,7 +21,17 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
private private
def account_params def account_params
params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) params.permit(
:display_name,
:note,
:avatar,
:header,
:locked,
:bot,
:discoverable,
:hide_collections,
fields_attributes: [:name, :value]
)
end end
def user_settings_params def user_settings_params

View File

@ -3,6 +3,14 @@
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
before_action -> { authorize_if_got_token! :'admin:read' } before_action -> { authorize_if_got_token! :'admin:read' }
def index
if current_user&.can?(:manage_taxonomies)
render json: @tags, each_serializer: REST::Admin::TagSerializer
else
super
end
end
private private
def enabled? def enabled?

View File

@ -81,6 +81,7 @@ class Api::V1::StatusesController < Api::BaseController
current_account.id, current_account.id,
text: status_params[:status], text: status_params[:status],
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
media_attributes: status_params[:media_attributes],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
language: status_params[:language], language: status_params[:language],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -133,6 +134,12 @@ class Api::V1::StatusesController < Api::BaseController
:quote_id, :quote_id,
:content_type, :content_type,
media_ids: [], media_ids: [],
media_attributes: [
:id,
:thumbnail,
:description,
:focus,
],
poll: [ poll: [
:multiple, :multiple,
:hide_totals, :hide_totals,

View File

@ -26,14 +26,4 @@ module AdminExportControllerConcern
def import_params def import_params
params.require(:admin_import).permit(:data) params.require(:admin_import).permit(:data)
end end
def import_data_path
params[:admin_import][:data].path
end
def parse_import_data!(default_headers)
data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
@data = data.reject(&:blank?)
end
end end

View File

@ -46,11 +46,11 @@ module SignatureVerification
end end
def require_account_signature! def require_account_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end end
def require_actor_signature! def require_actor_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
end end
def signed_request? def signed_request?
@ -97,11 +97,11 @@ module SignatureVerification
actor = stoplight_wrap_request { actor_refresh_key!(actor) } actor = stoplight_wrap_request { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil? raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
return actor unless verify_signature(actor, signature, compare_signed_string).nil? 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)" 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']
rescue SignatureVerificationError => e rescue SignatureVerificationError => e
fail_with! e.message fail_with! e.message
rescue HTTP::Error, OpenSSL::SSL::SSLError => e rescue HTTP::Error, OpenSSL::SSL::SSLError => e
@ -118,8 +118,8 @@ module SignatureVerification
private private
def fail_with!(message) def fail_with!(message, **options)
@signature_verification_failure_reason = message @signature_verification_failure_reason = { error: message }.merge(options)
@signed_request_actor = nil @signed_request_actor = nil
end end
@ -209,8 +209,8 @@ module SignatureVerification
end end
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present? expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError rescue ArgumentError => e
return false raise SignatureVerificationError, "Invalid Date header: #{e.message}"
end end
expires_time ||= created_time + 5.minutes unless created_time.nil? expires_time ||= created_time + 5.minutes unless created_time.nil?

View File

@ -4,22 +4,17 @@ module WebAppControllerConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
prepend_before_action :redirect_unauthenticated_to_permalinks!
before_action :set_pack before_action :set_pack
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_app_body_class before_action :set_app_body_class
before_action :set_referrer_policy_header
end end
def set_app_body_class def set_app_body_class
@body_classes = 'app-body' @body_classes = 'app-body'
end end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin'
end
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
redirect_path = PermalinkRedirector.new(request.path).redirect_path redirect_path = PermalinkRedirector.new(request.path).redirect_path

View File

@ -20,7 +20,7 @@ module Admin::ActionLogsHelper
when 'Status' when 'Status'
link_to log.human_identifier, log.permalink link_to log.human_identifier, log.permalink
when 'AccountWarning' when 'AccountWarning'
link_to log.human_identifier, admin_account_path(log.target_id) link_to log.human_identifier, disputes_strike_path(log.target_id)
when 'Announcement' when 'Announcement'
link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id) link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id)
when 'IpBlock', 'Instance', 'CustomEmoji' when 'IpBlock', 'Instance', 'CustomEmoji'

View File

@ -194,7 +194,7 @@ ready(() => {
} }
document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => { document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
const domain = document.getElementById('by_domain')?.value; const domain = document.querySelector('input[type="text"]#by_domain')?.value;
if (domain) { if (domain) {
const url = new URL(event.target.href); const url = new URL(event.target.href);

View File

@ -203,6 +203,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
// If we're editing a post with media attachments, those have not
// necessarily been changed on the server. Do it now in the same
// API call.
let media_attributes;
if (statusId !== null) {
media_attributes = media.map(item => ({
id: item.get('id'),
description: item.get('description'),
focus: item.get('focus'),
}));
}
api(getState).request({ api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put', method: statusId === null ? 'post' : 'put',
@ -212,6 +224,7 @@ export function submitCompose(routerHistory) {
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
quote_id: getState().getIn(['compose', 'quote_id'], null), quote_id: getState().getIn(['compose', 'quote_id'], null),
media_ids: media.map(item => item.get('id')), media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText, spoiler_text: spoilerText,
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
@ -438,11 +451,31 @@ export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, params).then(response => { let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => { // Editing already-attached media is deferred to editing the post itself.
dispatch(changeUploadComposeFail(id, error)); // For simplicity's sake, fake an API reply.
}); if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
}
dispatch(changeUploadComposeSuccess(data, true));
} else {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data, false));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
}
}; };
}; };
@ -453,10 +486,11 @@ export function changeUploadComposeRequest() {
}; };
}; };
export function changeUploadComposeSuccess(media) { export function changeUploadComposeSuccess(media, attached) {
return { return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media, media: media,
attached: attached,
skipLoading: true, skipLoading: true,
}; };
}; };

View File

@ -1,8 +1,8 @@
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
export function openDropdownMenu(id, placement, keyboard, scroll_key) { export function openDropdownMenu(id, keyboard, scroll_key) {
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key }; return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key };
} }
export function closeDropdownMenu(id) { export function closeDropdownMenu(id) {

View File

@ -1,9 +1,17 @@
import api from '../api'; import api, { getLinks } from '../api';
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
error, error,
}); });
export const fetchFollowedHashtags = () => (dispatch, getState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
};
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
};
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
};
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
};
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
};
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
};
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
};
export const followHashtag = name => (dispatch, getState) => { export const followHashtag = name => (dispatch, getState) => {
dispatch(followHashtagRequest(name)); dispatch(followHashtagRequest(name));

View File

@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
<Hashtag <Hashtag
key={hashtag.name} key={hashtag.name}
name={hashtag.name} name={hashtag.name}
href={`/admin/tags/${hashtag.id}`} href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1} people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1} uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)} history={hashtag.history.reverse().map(day => day.uses)}

View File

@ -50,6 +50,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
id: PropTypes.string, id: PropTypes.string,
searchTokens: PropTypes.arrayOf(PropTypes.string), searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number, maxLength: PropTypes.number,
lang: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -185,7 +186,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
return ( return (
@ -210,6 +211,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
id={id} id={id}
className={className} className={className}
maxLength={maxLength} maxLength={maxLength}
lang={lang}
/> />
</label> </label>

View File

@ -48,6 +48,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
lang: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -192,7 +193,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
return [ return [
@ -216,6 +217,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onPaste={this.onPaste} onPaste={this.onPaste}
dir='auto' dir='auto'
aria-autocomplete='list' aria-autocomplete='list'
lang={lang}
/> />
</label> </label>
</div> </div>

View File

@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { CircularProgress } from 'flavours/glitch/components/loading_indicator'; import { CircularProgress } from 'flavours/glitch/components/loading_indicator';
@ -24,9 +22,6 @@ class DropdownMenu extends React.PureComponent {
scrollable: PropTypes.bool, scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
renderHeader: PropTypes.func, renderHeader: PropTypes.func,
@ -35,11 +30,6 @@ class DropdownMenu extends React.PureComponent {
static defaultProps = { static defaultProps = {
style: {}, style: {},
placement: 'bottom',
};
state = {
mounted: false,
}; };
handleDocumentClick = e => { handleDocumentClick = e => {
@ -56,8 +46,6 @@ class DropdownMenu extends React.PureComponent {
if (this.focusedItem && this.props.openedViaKeyboard) { if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true }); this.focusedItem.focus({ preventScroll: true });
} }
this.setState({ mounted: true });
} }
componentWillUnmount () { componentWillUnmount () {
@ -139,40 +127,28 @@ class DropdownMenu extends React.PureComponent {
} }
render () { render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props; const { items, scrollable, renderHeader, loading } = this.props;
const { mounted } = this.state;
let renderItem = this.props.renderItem || this.renderItem; let renderItem = this.props.renderItem || this.renderItem;
return ( return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{({ opacity, scaleX, scaleY }) => ( {loading && (
// It should not be transformed when mounting because the resulting <CircularProgress size={30} strokeWidth={3.5} />
// size will be used to determine the coordinate of the menu by )}
// react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}> {!loading && renderHeader && (
{loading && ( <div className='dropdown-menu__container__header'>
<CircularProgress size={30} strokeWidth={3.5} /> {renderHeader(items)}
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
</div> </div>
)} )}
</Motion>
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
); );
} }
@ -197,7 +173,6 @@ export default class Dropdown extends React.PureComponent {
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired, onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number, openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
@ -213,13 +188,11 @@ export default class Dropdown extends React.PureComponent {
id: id++, id: id++,
}; };
handleClick = ({ target, type }) => { handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) { if (this.state.id === this.props.openDropdownId) {
this.handleClose(); this.handleClose();
} else { } else {
const { top } = target.getBoundingClientRect(); this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
} }
} }
@ -303,7 +276,6 @@ export default class Dropdown extends React.PureComponent {
disabled, disabled,
loading, loading,
scrollable, scrollable,
dropdownPlacement,
openDropdownId, openDropdownId,
openedViaKeyboard, openedViaKeyboard,
children, children,
@ -314,7 +286,6 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId; const open = this.state.id === openDropdownId;
const button = children ? React.cloneElement(React.Children.only(children), { const button = children ? React.cloneElement(React.Children.only(children), {
ref: this.setTargetRef,
onClick: this.handleClick, onClick: this.handleClick,
onMouseDown: this.handleMouseDown, onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown, onKeyDown: this.handleButtonKeyDown,
@ -326,7 +297,6 @@ export default class Dropdown extends React.PureComponent {
active={open} active={open}
disabled={disabled} disabled={disabled}
size={size} size={size}
ref={this.setTargetRef}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown} onKeyDown={this.handleButtonKeyDown}
@ -336,19 +306,27 @@ export default class Dropdown extends React.PureComponent {
return ( return (
<React.Fragment> <React.Fragment>
{button} <span ref={this.setTargetRef}>
{button}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> </span>
<DropdownMenu <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
items={items} {({ props, arrowProps, placement }) => (
loading={loading} <div {...props}>
scrollable={scrollable} <div className={`dropdown-animation dropdown-menu ${placement}`}>
onClose={this.handleClose} <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
openedViaKeyboard={openedViaKeyboard} <DropdownMenu
renderItem={renderItem} items={items}
renderHeader={renderHeader} loading={loading}
onItemClick={this.handleItemClick} scrollable={scrollable}
/> onClose={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</React.Fragment> </React.Fragment>
); );

View File

@ -4,7 +4,6 @@ import { fetchHistory } from 'flavours/glitch/actions/history';
import DropdownMenu from 'flavours/glitch/components/dropdown_menu'; import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
items: state.getIn(['history', statusId, 'items']), items: state.getIn(['history', statusId, 'items']),
@ -13,9 +12,9 @@ const mapStateToProps = (state, { statusId }) => ({
const mapDispatchToProps = (dispatch, { statusId }) => ({ const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, dropdownPlacement, keyboard) { onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId)); dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu(id, dropdownPlacement, keyboard)); dispatch(openDropdownMenu(id, keyboard));
}, },
onClose (id) { onClose (id) {

View File

@ -30,6 +30,7 @@ export default class IconButton extends React.PureComponent {
counter: PropTypes.number, counter: PropTypes.number,
obfuscateCount: PropTypes.bool, obfuscateCount: PropTypes.bool,
href: PropTypes.string, href: PropTypes.string,
ariaHidden: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -39,6 +40,7 @@ export default class IconButton extends React.PureComponent {
animate: false, animate: false,
overlay: false, overlay: false,
tabIndex: '0', tabIndex: '0',
ariaHidden: false,
}; };
state = { state = {
@ -115,6 +117,7 @@ export default class IconButton extends React.PureComponent {
counter, counter,
obfuscateCount, obfuscateCount,
href, href,
ariaHidden,
} = this.props; } = this.props;
const { const {
@ -155,6 +158,7 @@ export default class IconButton extends React.PureComponent {
<button <button
aria-label={title} aria-label={title}
aria-expanded={expanded} aria-expanded={expanded}
aria-hidden={ariaHidden}
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}

View File

@ -376,7 +376,7 @@ class MediaGallery extends React.PureComponent {
</button> </button>
); );
} else if (visible) { } else if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />; spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />;
} else { } else {
spoilerButton = ( spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>

View File

@ -103,7 +103,7 @@ class Status extends ImmutablePureComponent {
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func, deployPictureInPicture: PropTypes.func,
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
pictureInPicture: PropTypes.shape({ pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool, inUse: PropTypes.bool,
available: PropTypes.bool, available: PropTypes.bool,
}), }),
@ -225,7 +225,7 @@ class Status extends ImmutablePureComponent {
// - The user has decided to collapse all notifications ('muted' // - The user has decided to collapse all notifications ('muted'
// statuses). // statuses).
// - The user has decided to collapse long statuses and the status is // - The user has decided to collapse long statuses and the status is
// over 400px (without media, or 650px with). // over the user set value (default 400 without media, or 610px with).
// - The status is a reply and the user has decided to collapse all // - The status is a reply and the user has decided to collapse all
// replies. // replies.
// - The status contains media and the user has decided to collapse all // - The status contains media and the user has decided to collapse all
@ -252,10 +252,15 @@ class Status extends ImmutablePureComponent {
// as it could cause surprising changes when receiving notifications // as it could cause surprising changes when receiving notifications
if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return; if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
let autoCollapseHeight = parseInt(autoCollapseSettings.get('height'));
if (status.get('media_attachments').size && !muted) {
autoCollapseHeight += 210;
}
if (collapse || if (collapse ||
autoCollapseSettings.get('all') || autoCollapseSettings.get('all') ||
(autoCollapseSettings.get('notifications') && muted) || (autoCollapseSettings.get('notifications') && muted) ||
(autoCollapseSettings.get('lengthy') && node.clientHeight > ((status.get('media_attachments').size && !muted) ? 650 : 400)) || (autoCollapseSettings.get('lengthy') && node.clientHeight > autoCollapseHeight) ||
(autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') || (autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
(autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) || (autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
(autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0) (autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
@ -604,7 +609,7 @@ class Status extends ImmutablePureComponent {
attachments = status.get('media_attachments'); attachments = status.get('media_attachments');
if (pictureInPicture.inUse) { if (pictureInPicture.get('inUse')) {
media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />); media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
mediaIcons.push('video-camera'); mediaIcons.push('video-camera');
} else if (attachments.size > 0) { } else if (attachments.size > 0) {
@ -632,7 +637,7 @@ class Status extends ImmutablePureComponent {
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
height={110} height={110}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}
visible={this.state.showMedia} visible={this.state.showMedia}
@ -661,7 +666,7 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia} visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility} onToggleVisibility={this.handleToggleMediaVisibility}
/>)} />)}

View File

@ -9,7 +9,7 @@ import { me } from 'flavours/glitch/initial_state';
import RelativeTimestamp from './relative_timestamp'; import RelativeTimestamp from './relative_timestamp';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import classNames from 'classnames'; import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -38,9 +38,10 @@ const messages = defineMessages({
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Embed' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
hide: { id: 'status.hide', defaultMessage: 'Hide post' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
@ -210,6 +211,7 @@ class StatusActionBar extends ImmutablePureComponent {
render () { render () {
const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
const { permissions } = this.context.identity;
const anonymousAccess = !me; const anonymousAccess = !me;
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
@ -265,19 +267,19 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) { if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null); menu.push(null);
if (accountAdminLink !== undefined) { if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push({ if (accountAdminLink !== undefined) {
text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
href: accountAdminLink(status.getIn(['account', 'id'])), }
}); if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
} }
if (statusAdminLink !== undefined) { if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
menu.push({ const domain = status.getIn(['account', 'acct']).split('@')[1];
text: intl.formatMessage(messages.admin_status), menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')),
});
} }
} }
} }

View File

@ -5,18 +5,17 @@ import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
import { isUserTouching } from '../is_mobile'; import { isUserTouching } from '../is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
}); });
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, dropdownPlacement, keyboard) { onOpen(id, onItemClick, keyboard) {
dispatch(isUserTouching() ? openModal('ACTIONS', { dispatch(isUserTouching() ? openModal('ACTIONS', {
status, status,
actions: items, actions: items,
onClick: onItemClick, onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey)); }) : openDropdownMenu(id, keyboard, scrollKey));
}, },
onClose(id) { onClose(id) {

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Status from 'flavours/glitch/components/status'; import Status from 'flavours/glitch/components/status';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import { import {
replyCompose, replyCompose,
quoteCompose, quoteCompose,
@ -63,6 +63,7 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
@ -86,11 +87,7 @@ const makeMapStateToProps = () => {
account: account || props.account, account: account || props.account,
settings: state.get('local_settings'), settings: state.get('local_settings'),
prepend: prepend || props.prepend, prepend: prepend || props.prepend,
pictureInPicture: getPictureInPicture(state, props),
pictureInPicture: {
inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id,
available: state.getIn(['meta', 'layout']) !== 'mobile',
},
}; };
}; };

View File

@ -1,6 +1,3 @@
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import 'intersection-observer'; import 'intersection-observer';
import 'requestidlecallback'; import 'requestidlecallback';
import objectFitImages from 'object-fit-images';
objectFitImages();

View File

@ -14,7 +14,7 @@ import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
import FollowRequestNoteContainer from '../containers/follow_request_note_container'; import FollowRequestNoteContainer from '../containers/follow_request_note_container';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
@ -45,6 +45,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, 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' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -52,6 +53,7 @@ const messages = defineMessages({
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' }, add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
@ -155,7 +157,7 @@ class Header extends ImmutablePureComponent {
render () { render () {
const { account, hidden, intl, domain } = this.props; const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity; const { signedIn, permissions } = this.context.identity;
if (!account) { if (!account) {
return null; return null;
@ -187,7 +189,7 @@ class Header extends ImmutablePureComponent {
} }
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />; bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
} }
if (me !== account.get('id')) { if (me !== account.get('id')) {
@ -244,6 +246,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
@ -291,9 +294,14 @@ class Header extends ImmutablePureComponent {
} }
} }
if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) { if (account.get('id') !== me && ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) }); if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
}
} }
const content = { __html: account.get('note_emojified') }; const content = { __html: account.get('note_emojified') };
@ -313,6 +321,11 @@ class Header extends ImmutablePureComponent {
badge = null; badge = null;
} }
let role = null;
if (account.getIn(['roles', 0])) {
role = (<div key='role' className={`account-role user-role-${account.getIn(['roles', 0, 'id'])}`}>{account.getIn(['roles', 0, 'name'])}</div>);
}
return ( return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />} {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
@ -329,6 +342,7 @@ class Header extends ImmutablePureComponent {
<div className='account__header__tabs'> <div className='account__header__tabs'>
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}> <a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
<Avatar account={suspended || hidden ? undefined : account} size={90} /> <Avatar account={suspended || hidden ? undefined : account} size={90} />
{role}
</a> </a>
{!suspended && ( {!suspended && (

View File

@ -59,7 +59,7 @@ class Audio extends React.PureComponent {
duration: null, duration: null,
paused: true, paused: true,
muted: false, muted: false,
volume: 0.5, volume: 1,
dragging: false, dragging: false,
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
}; };
@ -80,8 +80,8 @@ class Audio extends React.PureComponent {
_pack() { _pack() {
return { return {
src: this.props.src, src: this.props.src,
volume: this.audio.volume, volume: this.state.volume,
muted: this.audio.muted, muted: this.state.muted,
currentTime: this.audio.currentTime, currentTime: this.audio.currentTime,
poster: this.props.poster, poster: this.props.poster,
backgroundColor: this.props.backgroundColor, backgroundColor: this.props.backgroundColor,
@ -117,7 +117,8 @@ class Audio extends React.PureComponent {
this.audio = c; this.audio = c;
if (this.audio) { if (this.audio) {
this.setState({ volume: this.audio.volume, muted: this.audio.muted }); this.audio.volume = 1;
this.audio.muted = false;
} }
} }
@ -208,7 +209,9 @@ class Audio extends React.PureComponent {
const muted = !this.state.muted; const muted = !this.state.muted;
this.setState({ muted }, () => { this.setState({ muted }, () => {
this.audio.muted = muted; if (this.gainNode) {
this.gainNode.gain.value = muted ? 0 : this.state.volume;
}
}); });
} }
@ -286,7 +289,9 @@ class Audio extends React.PureComponent {
if(!isNaN(x)) { if(!isNaN(x)) {
this.setState({ volume: x }, () => { this.setState({ volume: x }, () => {
this.audio.volume = x; if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : x;
}
}); });
} }
}, 15); }, 15);
@ -319,20 +324,12 @@ class Audio extends React.PureComponent {
} }
handleLoadedData = () => { handleLoadedData = () => {
const { autoPlay, currentTime, volume, muted } = this.props; const { autoPlay, currentTime } = this.props;
if (currentTime) { if (currentTime) {
this.audio.currentTime = currentTime; this.audio.currentTime = currentTime;
} }
if (volume !== undefined) {
this.audio.volume = volume;
}
if (muted !== undefined) {
this.audio.muted = muted;
}
if (autoPlay) { if (autoPlay) {
this.togglePlay(); this.togglePlay();
} }
@ -342,11 +339,16 @@ class Audio extends React.PureComponent {
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext(); const context = new AudioContext();
const source = context.createMediaElementSource(this.audio); const source = context.createMediaElementSource(this.audio);
const gainNode = context.createGain();
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
this.visualizer.setAudioContext(context, source); this.visualizer.setAudioContext(context, source);
source.connect(context.destination); source.connect(gainNode);
gainNode.connect(context.destination);
this.audioContext = context; this.audioContext = context;
this.gainNode = gainNode;
} }
handleDownload = () => { handleDownload = () => {

View File

@ -12,6 +12,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, 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' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -46,6 +47,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View File

@ -62,6 +62,7 @@ class ComposeForm extends ImmutablePureComponent {
anyMedia: PropTypes.bool, anyMedia: PropTypes.bool,
isInReply: PropTypes.bool, isInReply: PropTypes.bool,
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
lang: PropTypes.string,
advancedOptions: ImmutablePropTypes.map, advancedOptions: ImmutablePropTypes.map,
layout: PropTypes.string, layout: PropTypes.string,
@ -312,7 +313,7 @@ class ComposeForm extends ImmutablePureComponent {
<ReplyIndicatorContainer /> <ReplyIndicatorContainer />
<QuoteIndicatorContainer /> <QuoteIndicatorContainer />
<div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}> <div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)} placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText} value={spoilerText}
@ -327,6 +328,7 @@ class ComposeForm extends ImmutablePureComponent {
searchTokens={[':']} searchTokens={[':']}
id='glitch.composer.spoiler.input' id='glitch.composer.spoiler.input'
className='spoiler-input__input' className='spoiler-input__input'
lang={this.props.lang}
autoFocus={false} autoFocus={false}
/> />
</div> </div>
@ -345,6 +347,7 @@ class ComposeForm extends ImmutablePureComponent {
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste} onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
lang={this.props.lang}
> >
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} /> <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
<TextareaIcons advancedOptions={advancedOptions} /> <TextareaIcons advancedOptions={advancedOptions} />

View File

@ -2,7 +2,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
// Components. // Components.
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
@ -45,7 +45,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
}; };
// Toggles opening and closing the dropdown. // Toggles opening and closing the dropdown.
handleToggle = ({ target, type }) => { handleToggle = ({ type }) => {
const { onModalOpen } = this.props; const { onModalOpen } = this.props;
const { open } = this.state; const { open } = this.state;
@ -59,11 +59,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
} }
} }
} else { } else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
} }
} }
@ -158,6 +156,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
}; };
} }
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
}
// Rendering. // Rendering.
render () { render () {
const { const {
@ -179,6 +189,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
<div <div
className={classNames('privacy-dropdown', placement, { active: open })} className={classNames('privacy-dropdown', placement, { active: open })}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
ref={this.setTargetRef}
> >
<div className={classNames('privacy-dropdown__value', { active })}> <div className={classNames('privacy-dropdown__value', { active })}>
<IconButton <IconButton
@ -204,18 +215,26 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
containerPadding={20} containerPadding={20}
placement={placement} placement={placement}
show={open} show={open}
target={this} flip
target={this.findTarget}
container={container} container={container}
popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
> >
<DropdownMenu {({ props, placement }) => (
items={items} <div {...props}>
renderItemContents={renderItemContents} <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
onChange={onChange} <DropdownMenu
onClose={this.handleClose} items={items}
value={value} renderItemContents={renderItemContents}
openedViaKeyboard={this.state.openedViaKeyboard} onChange={onChange}
closeOnChange={closeOnChange} onClose={this.handleClose}
/> value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
closeOnChange={closeOnChange}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -1,7 +1,6 @@
// Package imports. // Package imports.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames'; import classNames from 'classnames';
@ -10,15 +9,8 @@ import Icon from 'flavours/glitch/components/icon';
// Utils. // Utils.
import { withPassive } from 'flavours/glitch/utils/dom_helpers'; import { withPassive } from 'flavours/glitch/utils/dom_helpers';
import Motion from '../../ui/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/utils/react_helpers'; import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component. // The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent { export default class ComposerOptionsDropdownContent extends React.PureComponent {
@ -44,7 +36,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
}; };
state = { state = {
mounted: false,
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
}; };
@ -56,7 +47,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} }
// Stores our node in `this.node`. // Stores our node in `this.node`.
handleRef = (node) => { setRef = (node) => {
this.node = node; this.node = node;
} }
@ -69,7 +60,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} else { } else {
this.node.firstChild.focus({ preventScroll: true }); this.node.firstChild.focus({ preventScroll: true });
} }
this.setState({ mounted: true });
} }
// On unmounting, we remove our listeners. // On unmounting, we remove our listeners.
@ -191,7 +181,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// Rendering. // Rendering.
render () { render () {
const { mounted } = this.state;
const { const {
items, items,
onChange, onChange,
@ -201,36 +190,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// The result. // The result.
return ( return (
<Motion <div style={{ ...style }} role='listbox' ref={this.setRef}>
defaultStyle={{ {!!items && items.map((item, i) => this.renderItem(item, i))}
opacity: 0, </div>
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className='privacy-dropdown__dropdown'
ref={this.handleRef}
role='listbox'
style={{
...style,
opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}}
>
{!!items && items.map((item, i) => this.renderItem(item, i))}
</div>
)}
</Motion>
); );
} }

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
@ -157,9 +157,6 @@ class EmojiPickerMenu extends React.PureComponent {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
@ -330,14 +327,13 @@ class EmojiPickerDropdown extends React.PureComponent {
state = { state = {
active: false, active: false,
loading: false, loading: false,
placement: null,
}; };
setRef = (c) => { setRef = (c) => {
this.dropdown = c; this.dropdown = c;
} }
onShowDropdown = ({ target }) => { onShowDropdown = () => {
this.setState({ active: true }); this.setState({ active: true });
if (!EmojiPicker) { if (!EmojiPicker) {
@ -352,9 +348,6 @@ class EmojiPickerDropdown extends React.PureComponent {
this.setState({ loading: false, active: false }); this.setState({ loading: false, active: false });
}); });
} }
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
} }
onHideDropdown = () => { onHideDropdown = () => {
@ -388,7 +381,7 @@ class EmojiPickerDropdown extends React.PureComponent {
render () { render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state; const { active, loading } = this.state;
return ( return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
@ -400,16 +393,22 @@ class EmojiPickerDropdown extends React.PureComponent {
/>} />}
</div> </div>
<Overlay show={active} placement={placement} target={this.findTarget}> <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<EmojiPickerMenu {({ props, placement })=> (
custom_emojis={this.props.custom_emojis} <div {...props} style={{ ...props.style, width: 299 }}>
loading={loading} <div className={`dropdown-animation ${placement}`}>
onClose={this.onHideDropdown} <EmojiPickerMenu
onPick={onPickEmoji} custom_emojis={this.props.custom_emojis}
onSkinTone={onSkinTone} loading={loading}
skinTone={skinTone} onClose={this.onHideDropdown}
frequentlyUsedEmojis={frequentlyUsedEmojis} onPick={onPickEmoji}
/> onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import TextIconButton from './text_icon_button'; import TextIconButton from './text_icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state'; import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
@ -22,10 +20,8 @@ const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent { class LanguageDropdownMenu extends React.PureComponent {
static propTypes = { static propTypes = {
style: PropTypes.object,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
@ -37,7 +33,6 @@ class LanguageDropdownMenu extends React.PureComponent {
}; };
state = { state = {
mounted: false,
searchValue: '', searchValue: '',
}; };
@ -50,7 +45,6 @@ class LanguageDropdownMenu extends React.PureComponent {
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
this.setState({ mounted: true });
// Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
// to wait for a frame before focusing // to wait for a frame before focusing
@ -222,29 +216,22 @@ class LanguageDropdownMenu extends React.PureComponent {
} }
render () { render () {
const { style, placement, intl } = this.props; const { intl } = this.props;
const { mounted, searchValue } = this.state; const { searchValue } = this.state;
const isSearching = searchValue !== ''; const isSearching = searchValue !== '';
const results = this.search(); const results = this.search();
return ( return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <div ref={this.setRef}>
{({ opacity, scaleX, scaleY }) => ( <div className='emoji-mart-search'>
// It should not be transformed when mounting because the resulting <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
// size will be used to determine the coordinate of the menu by <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
// react-overlays </div>
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)} {results.map(this.renderItem)}
</div> </div>
</div> </div>
)}
</Motion>
); );
} }
@ -266,14 +253,11 @@ class LanguageDropdown extends React.PureComponent {
placement: 'bottom', placement: 'bottom',
}; };
handleToggle = ({ target }) => { handleToggle = () => {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open }); this.setState({ open: !this.state.open });
} }
@ -293,13 +277,25 @@ class LanguageDropdown extends React.PureComponent {
onChange(value); onChange(value);
} }
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
}
render () { render () {
const { value, intl, frequentlyUsedLanguages } = this.props; const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
return ( return (
<div className={classNames('privacy-dropdown', { active: open })}> <div className={classNames('privacy-dropdown', placement, { active: open })}>
<div className='privacy-dropdown__value'> <div className='privacy-dropdown__value' ref={this.setTargetRef} >
<TextIconButton <TextIconButton
className='privacy-dropdown__value-icon' className='privacy-dropdown__value-icon'
label={value && value.toUpperCase()} label={value && value.toUpperCase()}
@ -309,15 +305,20 @@ class LanguageDropdown extends React.PureComponent {
/> />
</div> </div>
<Overlay show={open} placement={placement} target={this}> <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<LanguageDropdownMenu {({ props, placement }) => (
value={value} <div {...props}>
frequentlyUsedLanguages={frequentlyUsedLanguages} <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
onClose={this.handleClose} <LanguageDropdownMenu
onChange={this.handleChange} value={value}
placement={placement} frequentlyUsedLanguages={frequentlyUsedLanguages}
intl={intl} onClose={this.handleClose}
/> onChange={this.handleChange}
intl={intl}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -3,13 +3,12 @@ import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { import {
injectIntl, injectIntl,
FormattedMessage, FormattedMessage,
defineMessages, defineMessages,
} from 'react-intl'; } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
// Components. // Components.
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
@ -17,7 +16,6 @@ import Icon from 'flavours/glitch/components/icon';
// Utils. // Utils.
import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
import { searchEnabled } from 'flavours/glitch/initial_state'; import { searchEnabled } from 'flavours/glitch/initial_state';
import Motion from '../../ui/util/optional_motion';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
@ -26,31 +24,20 @@ const messages = defineMessages({
class SearchPopout extends React.PureComponent { class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () { render () {
const { style } = this.props;
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />; const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
return ( return (
<div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}> <div className='search-popout'>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
{({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul> <ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul> </ul>
{extraInformation} {extraInformation}
</div>
)}
</Motion>
</div> </div>
); );
} }
@ -136,6 +123,10 @@ class Search extends React.PureComponent {
} }
} }
findTarget = () => {
return this.searchForm;
}
render () { render () {
const { intl, value, submitted } = this.props; const { intl, value, submitted } = this.props;
const { expanded } = this.state; const { expanded } = this.state;
@ -161,8 +152,14 @@ class Search extends React.PureComponent {
<Icon id='search' className={hasValue ? '' : 'active'} /> <Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} /> <Icon id='times-circle' className={hasValue ? 'active' : ''} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}> <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<SearchPopout /> {({ props, placement }) => (
<div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
<div className={`dropdown-animation ${placement}`}>
<SearchPopout />
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
{({ scale }) => ( {({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__actions'> <div className='compose-form__upload__actions'>
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)} <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div> </div>
{(media.get('description') || '').length === 0 && !!media.get('unattached') && ( {(media.get('description') || '').length === 0 && (
<div className='compose-form__upload__warning'> <div className='compose-form__upload__warning'>
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button> <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div> </div>
)} )}
</div> </div>

View File

@ -70,6 +70,7 @@ function mapStateToProps (state) {
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null, isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
}; };
}; };

View File

@ -46,7 +46,7 @@ const mapDispatchToProps = (dispatch) => ({
}, },
onDoodleOpen() { onDoodleOpen() {
dispatch(openModal('DOODLE', { noEsc: true })); dispatch(openModal('DOODLE', { noEsc: true, noClose: true }));
}, },
}); });

View File

@ -0,0 +1,89 @@
import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import ColumnHeader from 'flavours/glitch/components/column_header';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import Column from 'flavours/glitch/features/ui/components/column';
import { Helmet } from 'react-helmet';
import Hashtag from 'flavours/glitch/components/hashtag';
import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
});
const mapStateToProps = state => ({
hashtags: state.getIn(['followed_tags', 'items']),
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
hasMore: !!state.getIn(['followed_tags', 'next']),
});
export default @connect(mapStateToProps)
@injectIntl
class FollowedTags extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hashtags: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentDidMount() {
this.props.dispatch(fetchFollowedHashtags());
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowedHashtags());
}, 300, { leading: true });
render () {
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
icon='hashtag'
title={intl.formatMessage(messages.heading)}
showBackButton
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='followed_tags'
emptyMessage={emptyMessage}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
bindToDocument={!multiColumn}
>
{hashtags.map((hashtag) => (
<Hashtag
key={hashtag.get('name')}
name={hashtag.get('name')}
to={`/tags/${hashtag.get('name')}`}
withGraph={false}
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
))}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}

View File

@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
// Our imports // Our imports
import { expandSpoilers, disableSwiping } from 'flavours/glitch/initial_state'; import { expandSpoilers } from 'flavours/glitch/initial_state';
import { preferenceLink } from 'flavours/glitch/utils/backend_links'; import { preferenceLink } from 'flavours/glitch/utils/backend_links';
import LocalSettingsPageItem from './item'; import LocalSettingsPageItem from './item';
import DeprecatedLocalSettingsPageItem from './deprecated_item'; import DeprecatedLocalSettingsPageItem from './deprecated_item';
@ -406,6 +406,18 @@ class LocalSettingsPage extends React.PureComponent {
> >
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
</LocalSettingsPageItem> </LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'height']}
id='mastodon-settings--collapsed-auto-height'
placeholder='400'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
inputProps={{type: 'number', min: '200', max: '999'}}
>
<FormattedMessage id='settings.auto_collapse_height' defaultMessage='Height (in pixels) for a toot to be considered lengthy' />
</LocalSettingsPageItem>
</section> </section>
<section> <section>
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>

View File

@ -14,6 +14,7 @@ export default class LocalSettingsPageItem extends React.PureComponent {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
item: PropTypes.array.isRequired, item: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
inputProps: PropTypes.object,
options: PropTypes.arrayOf(PropTypes.shape({ options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
message: PropTypes.string.isRequired, message: PropTypes.string.isRequired,
@ -34,7 +35,7 @@ export default class LocalSettingsPageItem extends React.PureComponent {
render () { render () {
const { handleChange } = this; const { handleChange } = this;
const { settings, item, id, options, children, dependsOn, dependsOnNot, placeholder, disabled } = this.props; const { settings, item, id, inputProps, options, children, dependsOn, dependsOnNot, placeholder, disabled } = this.props;
let enabled = !disabled; let enabled = !disabled;
if (dependsOn) { if (dependsOn) {
@ -54,14 +55,17 @@ export default class LocalSettingsPageItem extends React.PureComponent {
let optionId = `${id}--${opt.value}`; let optionId = `${id}--${opt.value}`;
return ( return (
<label htmlFor={optionId}> <label htmlFor={optionId}>
<input type='radio' <input
type='radio'
name={id} name={id}
id={optionId} id={optionId}
key={optionId}
value={opt.value} value={opt.value}
onBlur={handleChange} onBlur={handleChange}
onChange={handleChange} onChange={handleChange}
checked={ currentValue === opt.value } checked={currentValue === opt.value}
disabled={!enabled} disabled={!enabled}
{...inputProps}
/> />
{opt.message} {opt.message}
{opt.hint && <span className='hint'>{opt.hint}</span>} {opt.hint && <span className='hint'>{opt.hint}</span>}
@ -89,6 +93,7 @@ export default class LocalSettingsPageItem extends React.PureComponent {
placeholder={placeholder} placeholder={placeholder}
onChange={handleChange} onChange={handleChange}
disabled={!enabled} disabled={!enabled}
{...inputProps}
/> />
</p> </p>
</label> </label>
@ -103,7 +108,8 @@ export default class LocalSettingsPageItem extends React.PureComponent {
checked={settings.getIn(item)} checked={settings.getIn(item)}
onChange={handleChange} onChange={handleChange}
disabled={!enabled} disabled={!enabled}
/> {...inputProps}
/>
{children} {children}
</label> </label>
</div> </div>

View File

@ -124,6 +124,7 @@ export default class Notification extends ImmutablePureComponent {
onMoveDown={onMoveDown} onMoveDown={onMoveDown}
onMoveUp={onMoveUp} onMoveUp={onMoveUp}
onMention={onMention} onMention={onMention}
contextType='notifications'
getScrollPosition={getScrollPosition} getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom} updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}
@ -146,6 +147,7 @@ export default class Notification extends ImmutablePureComponent {
onMoveDown={onMoveDown} onMoveDown={onMoveDown}
onMoveUp={onMoveUp} onMoveUp={onMoveUp}
onMention={onMention} onMention={onMention}
contextType='notifications'
getScrollPosition={getScrollPosition} getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom} updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}
@ -168,6 +170,7 @@ export default class Notification extends ImmutablePureComponent {
onMoveDown={onMoveDown} onMoveDown={onMoveDown}
onMoveUp={onMoveUp} onMoveUp={onMoveUp}
onMention={onMention} onMention={onMention}
contextType='notifications'
getScrollPosition={getScrollPosition} getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom} updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}
@ -190,6 +193,7 @@ export default class Notification extends ImmutablePureComponent {
onMoveDown={onMoveDown} onMoveDown={onMoveDown}
onMoveUp={onMoveUp} onMoveUp={onMoveUp}
onMention={onMention} onMention={onMention}
contextType='notifications'
getScrollPosition={getScrollPosition} getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom} updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}
@ -212,6 +216,7 @@ export default class Notification extends ImmutablePureComponent {
onMoveDown={onMoveDown} onMoveDown={onMoveDown}
onMoveUp={onMoveUp} onMoveUp={onMoveUp}
onMention={onMention} onMention={onMention}
contextType='notifications'
getScrollPosition={getScrollPosition} getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom} updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}

View File

@ -7,7 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import classNames from 'classnames'; import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -35,6 +35,7 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Embed' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
}); });
@ -183,19 +184,19 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) { if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null); menu.push(null);
if (accountAdminLink !== undefined) { if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push({ if (accountAdminLink !== undefined) {
text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
href: accountAdminLink(status.getIn(['account', 'id'])), }
}); if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
} }
if (statusAdminLink !== undefined) { if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
menu.push({ const domain = status.getIn(['account', 'acct']).split('@')[1];
text: intl.formatMessage(messages.admin_status), menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')),
});
} }
} }
} }

View File

@ -41,7 +41,10 @@ class DetailedStatus extends ImmutablePureComponent {
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
compact: PropTypes.bool, compact: PropTypes.bool,
showMedia: PropTypes.bool, showMedia: PropTypes.bool,
usingPiP: PropTypes.bool, pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func, onToggleMediaVisibility: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -120,7 +123,7 @@ class DetailedStatus extends ImmutablePureComponent {
render () { render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props; const { expanded, onToggleHidden, settings, pictureInPicture, intl } = this.props;
const outerStyle = { boxSizing: 'border-box' }; const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props; const { compact } = this.props;
@ -153,7 +156,7 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
} }
if (usingPiP) { if (pictureInPicture.get('inUse')) {
media.push(<PictureInPicturePlaceholder />); media.push(<PictureInPicturePlaceholder />);
mediaIcons.push('video-camera'); mediaIcons.push('video-camera');
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {

View File

@ -42,7 +42,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports'; import { initReport } from 'flavours/glitch/actions/reports';
import { initBoostModal } from 'flavours/glitch/actions/boosts'; import { initBoostModal } from 'flavours/glitch/actions/boosts';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
@ -73,6 +73,7 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const getAncestorsIds = createSelector([ const getAncestorsIds = createSelector([
(_, { id }) => id, (_, { id }) => id,
@ -130,11 +131,12 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId }); const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List(); let descendantsIds = Immutable.List();
if (status) { if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') }); descendantsIds = getDescendantsIds(state, { id: status.get('id') });
} }
@ -146,7 +148,7 @@ const makeMapStateToProps = () => {
settings: state.get('local_settings'), settings: state.get('local_settings'),
askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
}; };
}; };
@ -191,7 +193,10 @@ class Status extends ImmutablePureComponent {
askReplyConfirmation: PropTypes.bool, askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
usingPiP: PropTypes.bool, pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
}; };
state = { state = {
@ -620,7 +625,7 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; let ancestors, descendants;
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
if (isLoading) { if (isLoading) {
@ -698,7 +703,7 @@ class Status extends ImmutablePureComponent {
domain={domain} domain={domain}
showMedia={this.state.showMedia} showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility} onToggleMediaVisibility={this.handleToggleMediaVisibility}
usingPiP={usingPiP} pictureInPicture={pictureInPicture}
/> />
<ActionBar <ActionBar

View File

@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
<React.Fragment> <React.Fragment>
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label> <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} /> <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<label> <label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span> <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>

View File

@ -52,6 +52,8 @@ class LinkFooter extends React.PureComponent {
const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
const canProfileDirectory = profileDirectory; const canProfileDirectory = profileDirectory;
const DividingCircle = <span aria-hidden>{' · '}</span>;
return ( return (
<div className='link-footer'> <div className='link-footer'>
<p> <p>
@ -60,17 +62,17 @@ class LinkFooter extends React.PureComponent {
<Link key='about' to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link> <Link key='about' to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
{canInvite && ( {canInvite && (
<> <>
{' · '} {DividingCircle}
<a key='invites' href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a> <a key='invites' href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
</> </>
)} )}
{canProfileDirectory && ( {canProfileDirectory && (
<> <>
{' · '} {DividingCircle}
<Link key='directory' to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link> <Link key='directory' to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
</> </>
)} )}
{' · '} {DividingCircle}
<Link key='privacy-policy' to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link> <Link key='privacy-policy' to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
</p> </p>
@ -78,13 +80,13 @@ class LinkFooter extends React.PureComponent {
<strong>Mastodon</strong>: <strong>Mastodon</strong>:
{' '} {' '}
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a> <a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
{' · '} {DividingCircle}
<a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a> <a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a>
{' · '} {DividingCircle}
<Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link> <Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
{' · '} {DividingCircle}
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a> <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
{' · '} {DividingCircle}
v{version} v{version}
</p> </p>
</div> </div>

View File

@ -116,13 +116,16 @@ export default class ModalRoot extends React.PureComponent {
this._modal = c; this._modal = c;
} }
// prevent closing of modal when clicking the overlay
noop = () => {}
render () { render () {
const { type, props, ignoreFocus } = this.props; const { type, props, ignoreFocus } = this.props;
const { backgroundColor } = this.state; const { backgroundColor } = this.state;
const visible = !!type; const visible = !!type;
return ( return (
<Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}> <Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
{visible && ( {visible && (
<> <>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>

View File

@ -30,7 +30,7 @@ const SignInBanner = () => {
return ( return (
<div className='sign-in-banner'> <div className='sign-in-banner'>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p> <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a> <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
{signupButton} {signupButton}
</div> </div>

View File

@ -50,6 +50,7 @@ export default class VideoModal extends ImmutablePureComponent {
autoPlay={options.autoPlay} autoPlay={options.autoPlay}
volume={options.defaultVolume} volume={options.defaultVolume}
onCloseVideo={onClose} onCloseVideo={onClose}
autoFocus
detailed detailed
alt={media.get('description')} alt={media.get('description')}
/> />

View File

@ -42,6 +42,7 @@ import {
FollowRequests, FollowRequests,
FavouritedStatuses, FavouritedStatuses,
BookmarkedStatuses, BookmarkedStatuses,
FollowedTags,
ListTimeline, ListTimeline,
Blocks, Blocks,
DomainBlocks, DomainBlocks,
@ -56,7 +57,7 @@ import {
PrivacyPolicy, PrivacyPolicy,
} from './util/async-components'; } from './util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state'; import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
@ -177,7 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
} }
} else if (singleUserMode && owner && initialState?.accounts[owner]) { } else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />; redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends) { } else if (showTrends && trendsAsLanding) {
redirect = <Redirect from='/' to='/explore' exact />; redirect = <Redirect from='/' to='/explore' exact />;
} else { } else {
redirect = <Redirect from='/' to='/about' exact />; redirect = <Redirect from='/' to='/about' exact />;
@ -230,6 +231,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} /> <WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />

View File

@ -98,6 +98,10 @@ export function FavouritedStatuses () {
return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses'); return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
} }
export function FollowedTags () {
return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
}
export function BookmarkedStatuses () { export function BookmarkedStatuses () {
return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses'); return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
} }

View File

@ -124,6 +124,7 @@ class Video extends React.PureComponent {
volume: PropTypes.number, volume: PropTypes.number,
muted: PropTypes.bool, muted: PropTypes.bool,
componentIndex: PropTypes.number, componentIndex: PropTypes.number,
autoFocus: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -537,7 +538,7 @@ class Video extends React.PureComponent {
} }
render () { render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, editable, blurhash } = this.props; const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100); const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {}; const playerStyle = {};
@ -635,7 +636,7 @@ class Video extends React.PureComponent {
<div className='video-player__buttons-bar'> <div className='video-player__buttons-bar'>
<div className='video-player__buttons left'> <div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={autoFocus}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>

View File

@ -75,6 +75,7 @@
* @property {boolean} timeline_preview * @property {boolean} timeline_preview
* @property {string} title * @property {string} title
* @property {boolean} trends * @property {boolean} trends
* @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal * @property {boolean} unfollow_modal
* @property {boolean} use_blurhash * @property {boolean} use_blurhash
* @property {boolean=} use_pending_items * @property {boolean=} use_pending_items
@ -134,6 +135,7 @@ export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url'); export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview'); export const timelinePreview = getMeta('timeline_preview');
export const title = getMeta('title'); export const title = getMeta('title');
export const trendsAsLanding = getMeta('trends_as_landing_page');
export const unfollowModal = getMeta('unfollow_modal'); export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash'); export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');

View File

@ -23,15 +23,14 @@ function loadPolyfills() {
); );
// Latest version of Firefox and Safari do not have IntersectionObserver. // Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback and object-fit CSS property. // Edge does not have requestIdleCallback.
// This avoids shipping them all the polyfills. // This avoids shipping them all the polyfills.
const needsExtraPolyfills = !( const needsExtraPolyfills = !(
window.AbortController && window.AbortController &&
window.IntersectionObserver && window.IntersectionObserver &&
window.IntersectionObserverEntry && window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype && 'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback && window.requestIdleCallback
'object-fit' in (new Image()).style
); );
return Promise.all([ return Promise.all([

View File

@ -103,6 +103,7 @@
"settings.auto_collapse_all": "Alles", "settings.auto_collapse_all": "Alles",
"settings.auto_collapse_lengthy": "Lange Toots", "settings.auto_collapse_lengthy": "Lange Toots",
"settings.auto_collapse_media": "Toots mit Anhängen", "settings.auto_collapse_media": "Toots mit Anhängen",
"settings.auto_collapse_height": "Höhe (in Pixeln), ab der ein Toot als lang gilt",
"settings.auto_collapse_notifications": "Benachrichtigungen", "settings.auto_collapse_notifications": "Benachrichtigungen",
"settings.auto_collapse_reblogs": "Geteilte Toots", "settings.auto_collapse_reblogs": "Geteilte Toots",
"settings.auto_collapse_replies": "Antworten", "settings.auto_collapse_replies": "Antworten",

View File

@ -103,6 +103,7 @@
"settings.auto_collapse_all": "Everything", "settings.auto_collapse_all": "Everything",
"settings.auto_collapse_lengthy": "Lengthy toots", "settings.auto_collapse_lengthy": "Lengthy toots",
"settings.auto_collapse_media": "Toots with media", "settings.auto_collapse_media": "Toots with media",
"settings.auto_collapse_height": "Height (in pixels) for a toot to be considered lengthy",
"settings.auto_collapse_notifications": "Notifications", "settings.auto_collapse_notifications": "Notifications",
"settings.auto_collapse_reblogs": "Boosts", "settings.auto_collapse_reblogs": "Boosts",
"settings.auto_collapse_replies": "Replies", "settings.auto_collapse_replies": "Replies",

View File

@ -1,6 +1,52 @@
{ {
"account.add_account_note": "Aldoni noton por @{name}",
"account_note.cancel": "Nuligi",
"account_note.edit": "Redakti",
"account_note.save": "Konservi",
"column.reblogged_by": "Diskonigita de",
"column.subheading": "Diversaj agordoj",
"column_header.profile": "Profilo",
"column_subheading.lists": "Listoj",
"compose.attach": "Aldoni…",
"compose.attach.doodle": "Desegni ion",
"compose.attach.upload": "Alŝuti dosieron",
"compose.content-type.html": "HTML",
"compose.content-type.markdown": "Markdown",
"confirmations.unfilter.author": "Aŭtoro",
"confirmations.unfilter.confirm": "Montri",
"confirmations.unfilter.edit_filter": "Redakti filtrilon",
"navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
"notification_purge.btn_all": "Selekti ĉiujn",
"notification_purge.btn_apply": "Forigi selektajn",
"notification_purge.btn_invert": "Inverti selekton",
"notification_purge.btn_none": "Elekti neniun",
"notifications.marked_clear": "Forigi selektajn sciigojn",
"onboarding.next": "Sekva",
"onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_six.almost_done": "Preskaŭ finita…",
"onboarding.page_six.apps_available": "Estas {apps} disponeblaj por iOS, Android kaj aliaj sistemoj.",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.various_app": "poŝtelefonaj aplikaĵoj",
"settings.auto_collapse_all": "Ĉiuj",
"settings.auto_collapse_lengthy": "Longaj afiŝoj",
"settings.auto_collapse_media": "Afiŝoj kun aŭdovidaĵoj",
"settings.auto_collapse_notifications": "Sciigoj",
"settings.auto_collapse_reblogs": "Diskonigoj",
"settings.auto_collapse_replies": "Respondoj",
"settings.close": "Fermi",
"settings.content_warnings": "Content warnings", "settings.content_warnings": "Content warnings",
"settings.preferences": "Preferences" "settings.content_warnings.regexp": "Regula esprimo",
"settings.preferences": "Preferences",
"settings.shared_settings_link": "preferoj de uzanto",
"settings.side_arm": "Duaranga butono por afiŝi:",
"settings.side_arm.none": "Neniu",
"settings.status_icons": "Ikonoj sur la afiŝoj",
"settings.status_icons_language": "Indikilo de lingvo",
"settings.status_icons_media": "Indikilo de aŭdovidaĵojn kaj balotenketo",
"settings.status_icons_reply": "Indikilo de respondoj",
"settings.status_icons_visibility": "Indikilo de privateco de afiŝo",
"web_app_crash.change_your_settings": "Ŝanĝi viajn {settings}",
"web_app_crash.reload": "Reŝarĝi",
"web_app_crash.reload_page": "{reload} la nunan paĝon",
"web_app_crash.settings": "agordojn"
} }

View File

@ -1,6 +1,200 @@
{ {
"about.fork_disclaimer": "O Glitch-soc é um software gratuito de código aberto bifurcado a partir do Mastodon.",
"account.add_account_note": "Adicionar nota para @{name}",
"account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de forma incompleta.",
"account.follows": "Seguidores",
"account.joined": "Entrou em {date}",
"account.suspended_disclaimer_full": "Este usuário foi suspenso por um moderador.",
"account.view_full_profile": "Ver o perfil completo",
"account_note.cancel": "Cancelar",
"account_note.edit": "Editar",
"account_note.glitch_placeholder": "Nenhum comentário fornecido",
"account_note.save": "Salvar",
"advanced_options.icon_title": "Opções avançadas",
"advanced_options.local-only.long": "Não publicar em outras instâncias",
"advanced_options.local-only.short": "Apenas localmente",
"advanced_options.local-only.tooltip": "Este post é somente local",
"advanced_options.threaded_mode.long": "Abrir automaticamente uma resposta ao postar",
"advanced_options.threaded_mode.short": "Modo de discussão",
"advanced_options.threaded_mode.tooltip": "Modo de discussão ativado",
"boost_modal.missing_description": "Este toot contém algumas mídias sem descrição",
"column.favourited_by": "Favoritado por",
"column.heading": "Diversos",
"column.reblogged_by": "Inpulsionado por",
"column.subheading": "Opções diversas",
"column_header.profile": "Perfil",
"column_subheading.lists": "Listas",
"column_subheading.navigation": "Navegação",
"community.column_settings.allow_local_only": "Mostrar os toots apenas locais",
"compose.attach": "Anexar...",
"compose.attach.doodle": "Desenhe algo",
"compose.attach.upload": "Enviar um arquivo",
"compose.content-type.html": "HTML",
"compose.content-type.markdown": "Markdown",
"compose.content-type.plain": "Texto sem formatação",
"compose_form.poll.multiple_choices": "Permitir múltipla escolha",
"compose_form.poll.single_choice": "Permitir uma escolha",
"compose_form.spoiler": "Ocultar texto atrás do aviso",
"confirmation_modal.do_not_ask_again": "Não pedir confirmação novamente",
"confirmations.deprecated_settings.confirm": "Usar preferências do Mastodon",
"confirmations.deprecated_settings.message": "Alguns dos {app_settings} específicos do dispositivo que você está usando foram substituídos por Mastodon {preferences} e serão substituídos:",
"confirmations.missing_media_description.confirm": "Enviar mesmo assim",
"confirmations.missing_media_description.edit": "Editar mídia",
"confirmations.missing_media_description.message": "Pelo menos um anexo de mídia não tem uma descrição. Considere descrever todos os anexos de mídia para deficientes visuais antes de enviar seu toot.",
"confirmations.unfilter.author": "Autor",
"confirmations.unfilter.confirm": "Exibir",
"confirmations.unfilter.edit_filter": "Editar filtro",
"confirmations.unfilter.filters": "Correspondência de {count, plural, one {filtro} other {filtros}}",
"content-type.change": "Tipo de conteúdo",
"direct.group_by_conversations": "Agrupar por conversa",
"endorsed_accounts_editor.endorsed_accounts": "Contas em destaque",
"favourite_modal.combo": "Você pode pressionar {combo} para pular isso da próxima vez",
"getting_started.onboarding": "Mostre-me ao redor",
"home.column_settings.advanced": "Avançado",
"home.column_settings.filter_regex": "Filtrar com uma expressão regular",
"home.column_settings.show_direct": "Mostrar DMs",
"home.settings": "Configurações da coluna",
"keyboard_shortcuts.bookmark": "para marcar",
"keyboard_shortcuts.secondary_toot": "para enviar toot usando a configuração de privacidade secundária",
"keyboard_shortcuts.toggle_collapse": "para recolher/mostrar toots",
"layout.auto": "Automático",
"layout.desktop": "Área de trabalho",
"layout.hint.auto": "Escolher automaticamente o layout baseado na configuração \"Habilitar interface web avançada\" e o tamanho da tela.",
"layout.hint.desktop": "Use o layout de várias colunas independentemente da configuração \"Habilitar interface web avançada\" ou do tamanho da tela.",
"layout.hint.single": "Use o layout de uma coluna independentemente da configuração \"Habilitar interface web avançada\" ou do tamanho da tela.",
"layout.single": "Celular",
"media_gallery.sensitive": "Sensível",
"moved_to_warning": "Esta conta foi como movida para {moved_to_link} e, portanto, pode não aceitar novos seguidores.",
"navigation_bar.app_settings": "Configurações do aplicativo",
"navigation_bar.featured_users": "Usuários em destaque",
"navigation_bar.info": "Informação estendida",
"navigation_bar.keyboard_shortcuts": "Atalhos de teclado",
"navigation_bar.misc": "Diversos",
"notification.markForDeletion": "Marcar para exclusão",
"notification_purge.btn_all": "Selecionar\ntudo",
"notification_purge.btn_apply": "Limpar\nselecionados",
"notification_purge.btn_invert": "Inverter\nseleção",
"notification_purge.btn_none": "Selecionar\nnenhum",
"notification_purge.start": "Entrar no modo de limpeza de notificação",
"notifications.marked_clear": "Limpar as notificações selecionadas",
"notifications.marked_clear_confirmation": "Tem certeza que deseja limpar todas as notificações selecionadas permanentemente?",
"onboarding.done": "Feito",
"onboarding.next": "Próximo",
"onboarding.page_five.public_timelines": "A linha do tempo local mostra publicações públicas de todos em {domain}. A linha do tempo federada mostra publicações públicas de todos que as pessoas seguem em {domain}. Estas são as linhas do tempo públicas, uma ótima maneira de descobrir novas pessoas.",
"onboarding.page_four.home": "A linha do tempo da casa mostra publicações de pessoas que você segue.",
"onboarding.page_four.notifications": "A coluna de notificações mostra quando alguém interage com você.",
"onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.handle": "Você está em {domain}, então o seu identificador completo é {handle}",
"onboarding.page_one.welcome": "Bem-vindo ao {domain}!",
"onboarding.page_six.admin": "O administrador da sua instância é {admin}.",
"onboarding.page_six.almost_done": "Quase pronto...",
"onboarding.page_six.appetoot": "Bom Appetoot!",
"onboarding.page_six.apps_available": "Há {apps} disponíveis para iOS, Android e outras plataformas.",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "diretrizes da comunidade",
"onboarding.page_six.read_guidelines": "Por favor, leia {domain} {guidelines}!",
"onboarding.page_six.various_app": "aplicativos móveis",
"onboarding.page_three.profile": "Edite seu perfil para alterar seu avatar, bio e nome de exibição. Lá você também encontrará outras preferências.",
"onboarding.page_three.search": "Use a barra de busca para encontrar pessoas e procure hashtags, tais como {illustration} e {introductions}. Para procurar uma pessoa que não esteja neste caso, use o identificador completo.",
"onboarding.page_two.compose": "Escreva as postagens a partir da coluna de composição. Você pode enviar imagens, alterar as configurações de privacidade e adicionar avisos de conteúdo com os ícones abaixo.",
"onboarding.skip": "Pular",
"settings.always_show_spoilers_field": "Sempre ativar o campo Aviso de Conteúdo",
"settings.auto_collapse": "Colapso automático",
"settings.auto_collapse_all": "Tudo",
"settings.auto_collapse_lengthy": "Toots longos",
"settings.auto_collapse_media": "Toots com mídia",
"settings.auto_collapse_notifications": "Notificações",
"settings.auto_collapse_reblogs": "Impulsos",
"settings.auto_collapse_replies": "Respostas",
"settings.close": "Fechar",
"settings.collapsed_statuses": "Toots recolhidos",
"settings.compose_box_opts": "Caixa de composição",
"settings.confirm_before_clearing_draft": "Mostrar diálogo de confirmação antes de sobrescrever a mensagem que está sendo composta",
"settings.confirm_boost_missing_media_description": "Mostrar diálogo antes de inpulsionar os toots sem descrições de mídia",
"settings.confirm_missing_media_description": "Mostrar diálogo antes de enviar toots sem descrições de mídia",
"settings.content_warnings": "Content warnings", "settings.content_warnings": "Content warnings",
"settings.preferences": "Preferences" "settings.content_warnings.regexp": "Expressão regular",
"settings.content_warnings_filter": "Avisos de conteúdo para não revelar automaticamente:",
"settings.content_warnings_media_outside": "Exibir anexos de mídia fora avisos de conteúdo",
"settings.content_warnings_media_outside_hint": "Reproduzir o comportamento do Mastodonte, fazendo com que a alternância do Aviso de Conteúdo não afete os anexos de mídia",
"settings.content_warnings_shared_state": "Mostrar/ocultar o conteúdo de todas as cópias de uma só vez",
"settings.content_warnings_shared_state_hint": "Reproduzir o comportamento do Mastodonte fazendo com que o botão de Aviso de Conteúdo afete todas as cópias de um post de uma só vez. Isto evitará o colapso automático de qualquer cópia de um toon com Aviso de Conteúdo revelado",
"settings.content_warnings_unfold_opts": "Opções de auto-revelar",
"settings.deprecated_setting": "Essa configuração agora é controlada pelo {settings_page_link} do Mastodon",
"settings.enable_collapsed": "Habilitar toots recolhidos",
"settings.enable_collapsed_hint": "Posts recolhidos têm partes dos seus conteúdos ocultos para ocupar menos espaço na tela. Isto é diferente do recurso 'Aviso de Conteúdo'",
"settings.enable_content_warnings_auto_unfold": "Revelar automaticamente os avisos de conteúdo",
"settings.general": "Geral",
"settings.hicolor_privacy_icons": "Ícones de privacidade com cores de alto contraste",
"settings.hicolor_privacy_icons.hint": "Exibir ícones de privacidade em cores brilhantes e facilmente distinguíveis",
"settings.image_backgrounds": "Fundos de imagem",
"settings.image_backgrounds_media": "Pré-visualização da mídia de toots colapsados",
"settings.image_backgrounds_media_hint": "Se o post tiver algum anexo de mídia, use o primeiro em um plano de fundo",
"settings.image_backgrounds_users": "Dar a toots recolhidos uma imagem de fundo",
"settings.inline_preview_cards": "Cartões de pré-visualização em linha para links externos",
"settings.layout": "Layout:",
"settings.layout_opts": "Opções de layout",
"settings.media": "Mídia",
"settings.media_fullwidth": "Pré-visualização da mídia em largura total",
"settings.media_letterbox": "Caixa de mensagens",
"settings.media_letterbox_hint": "Escala para baixo para encher os recipientes de imagem em vez de esticá-los e cortá-los",
"settings.media_reveal_behind_cw": "Revelar mídia sensível por trás de um Aviso de Conteúdo por padrão",
"settings.notifications.favicon_badge": "Notificações não lidas como emblema do favicon",
"settings.notifications.favicon_badge.hint": "Adicionar um emblema para notificações não lidas ao favicon",
"settings.notifications.tab_badge": "Emblema de notificações não lidas",
"settings.notifications.tab_badge.hint": "Exibir um emblema para notificações não lidas nos ícones de coluna quando a coluna de notificações não estiver aberta",
"settings.notifications_opts": "Opções de notificações",
"settings.pop_in_left": "Esquerda",
"settings.pop_in_player": "Ativar player pop-in",
"settings.pop_in_position": "Posição do player:",
"settings.pop_in_right": "Direita",
"settings.preferences": "Preferences",
"settings.prepend_cw_re": "Preparar \"re: \" para avisos de conteúdo quando responder",
"settings.preselect_on_reply": "Nome de usuário pré-selecionado na resposta",
"settings.preselect_on_reply_hint": "Ao responder a uma conversa com vários participantes, pré-selecionar nomes de usuários após o primeiro",
"settings.rewrite_mentions": "Reescrever as menções nos status exibidos",
"settings.rewrite_mentions_acct": "Reescrever com nome de usuário e domínio (quando a conta for remota)",
"settings.rewrite_mentions_no": "Não reescrever menções",
"settings.rewrite_mentions_username": "Reescreva com nome de usuário",
"settings.shared_settings_link": "preferências do usuário",
"settings.show_action_bar": "Mostrar botões de ação em toots recolhidos",
"settings.show_content_type_choice": "Exibir opção do tipo de conteúdo ao autorar toots",
"settings.show_reply_counter": "Exibir uma estimativa da contagem de respostas",
"settings.side_arm": "Botão de toot secundário:",
"settings.side_arm.none": "Nenhum",
"settings.side_arm_reply_mode": "Ao responder a um toot, o botão secundário de toot deve:",
"settings.side_arm_reply_mode.copy": "Copiar configuração de privacidade do toot sendo respondido a",
"settings.side_arm_reply_mode.keep": "Mantenha sua privacidade definida",
"settings.side_arm_reply_mode.restrict": "Restringir configuração de privacidade ao toot sendo respondido a",
"settings.status_icons": "Ícones de toot",
"settings.status_icons_language": "Indicador de idioma",
"settings.status_icons_local_only": "Indicador somente local",
"settings.status_icons_media": "Indicadores de mídia e enquete",
"settings.status_icons_reply": "Indicador de resposta",
"settings.status_icons_visibility": "Indicador de privacidade",
"settings.swipe_to_change_columns": "Permitir deslizar para alterar colunas (apenas celular)",
"settings.tag_misleading_links": "Marcar links enganosos",
"settings.tag_misleading_links.hint": "Acrescentar uma indicação visual com o link hospedeiro alvo a cada link que não o mencione explicitamente",
"settings.wide_view": "Visualização ampla (apenas no Modo desktop)",
"settings.wide_view_hint": "Estica as colunas para preencher melhor o espaço disponível.",
"status.collapse": "Recolher",
"status.has_audio": "Possui um arquivo de áudio anexado",
"status.has_pictures": "Possui uma imagem anexada",
"status.has_preview_card": "Possui uma pré-visualização anexada",
"status.has_video": "Possui um vídeo anexado",
"status.in_reply_to": "Este toot é uma resposta",
"status.is_poll": "Este toot é uma enquete",
"status.local_only": "Visível apenas em sua instância",
"status.sensitive_toggle": "Clique para ver",
"status.uncollapse": "Revelar",
"web_app_crash.change_your_settings": "Altere suas {settings}",
"web_app_crash.content": "Você poderia tentar qualquer uma das seguintes opções:",
"web_app_crash.debug_info": "Informações de depuração",
"web_app_crash.disable_addons": "Desativar complementos do navegador ou ferramentas de tradução integradas",
"web_app_crash.issue_tracker": "rastreador de problemas",
"web_app_crash.reload": "Recarregar",
"web_app_crash.reload_page": "{reload} a página atual",
"web_app_crash.report_issue": "Relatar um erro no {issuetracker}",
"web_app_crash.settings": "configurações",
"web_app_crash.title": "Desculpe, mas algo deu errado com o aplicativo Mastodon."
} }

View File

@ -1,3 +1,4 @@
export const PERMISSION_INVITE_USERS = 0x0000000000010000; export const PERMISSION_INVITE_USERS = 0x0000000000010000;
export const PERMISSION_MANAGE_USERS = 0x0000000000000400; export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;

View File

@ -575,7 +575,7 @@ export default function compose(state = initialState, action) {
.setIn(['media_modal', 'dirty'], false) .setIn(['media_modal', 'dirty'], false)
.update('media_attachments', list => list.map(item => { .update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) { if (item.get('id') === action.media.id) {
return fromJS(action.media).set('unattached', true); return fromJS(action.media).set('unattached', !action.attached);
} }
return item; return item;

View File

@ -4,12 +4,12 @@ import {
DROPDOWN_MENU_CLOSE, DROPDOWN_MENU_CLOSE,
} from '../actions/dropdown_menu'; } from '../actions/dropdown_menu';
const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false, scroll_key: null }); const initialState = Immutable.Map({ openId: null, keyboard: false, scroll_key: null });
export default function dropdownMenu(state = initialState, action) { export default function dropdownMenu(state = initialState, action) {
switch (action.type) { switch (action.type) {
case DROPDOWN_MENU_OPEN: case DROPDOWN_MENU_OPEN:
return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard, scroll_key: action.scroll_key }); return state.merge({ openId: action.id, keyboard: action.keyboard, scroll_key: action.scroll_key });
case DROPDOWN_MENU_CLOSE: case DROPDOWN_MENU_CLOSE:
return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state; return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state;
default: default:

View File

@ -0,0 +1,42 @@
import {
FOLLOWED_HASHTAGS_FETCH_REQUEST,
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
FOLLOWED_HASHTAGS_FETCH_FAIL,
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
FOLLOWED_HASHTAGS_EXPAND_FAIL,
} from 'flavours/glitch/actions/tags';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
});
export default function followed_tags(state = initialState, action) {
switch(action.type) {
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
return state.set('isLoading', true);
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', fromJS(action.followed_tags));
map.set('isLoading', false);
map.set('next', action.next);
});
case FOLLOWED_HASHTAGS_FETCH_FAIL:
return state.set('isLoading', false);
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
return state.set('isLoading', true);
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
return state.withMutations(map => {
map.update('items', set => set.concat(fromJS(action.followed_tags)));
map.set('isLoading', false);
map.set('next', action.next);
});
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
return state.set('isLoading', false);
default:
return state;
}
};

View File

@ -42,6 +42,7 @@ import picture_in_picture from './picture_in_picture';
import accounts_map from './accounts_map'; import accounts_map from './accounts_map';
import history from './history'; import history from './history';
import tags from './tags'; import tags from './tags';
import followed_tags from './followed_tags';
const reducers = { const reducers = {
announcements, announcements,
@ -87,6 +88,7 @@ const reducers = {
picture_in_picture, picture_in_picture,
history, history,
tags, tags,
followed_tags,
}; };
export default combineReducers(reducers); export default combineReducers(reducers);

View File

@ -37,6 +37,7 @@ const initialState = ImmutableMap({
reblogs : false, reblogs : false,
replies : false, replies : false,
media : false, media : false,
height : 400,
}), }),
backgrounds : ImmutableMap({ backgrounds : ImmutableMap({
user_backgrounds : false, user_backgrounds : false,

View File

@ -1,6 +1,6 @@
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
import { toServerSideType } from 'flavours/glitch/utils/filters'; import { toServerSideType } from 'flavours/glitch/utils/filters';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
@ -74,6 +74,16 @@ export const makeGetStatus = () => {
); );
}; };
export const makeGetPictureInPicture = () => {
return createSelector([
(state, { id }) => state.get('picture_in_picture').statusId === id,
(state) => state.getIn(['meta', 'layout']) !== 'mobile',
], (inUse, available) => ImmutableMap({
inUse: inUse && available,
available,
}));
};
const getAlertsBase = state => state.get('alerts'); const getAlertsBase = state => state.get('alerts');
export const getAlerts = createSelector([getAlertsBase], (base) => { export const getAlerts = createSelector([getAlertsBase], (base) => {

View File

@ -214,7 +214,7 @@
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
font-weight: 500; font-weight: 500;
color: var(--user-role-accent, $ui-secondary-color); color: $ui-secondary-color;
background-color: var(--user-role-background, rgba($ui-secondary-color, 0.1)); background-color: var(--user-role-background, rgba($ui-secondary-color, 0.1));
border: 1px solid var(--user-role-border, rgba($ui-secondary-color, 0.5)); border: 1px solid var(--user-role-border, rgba($ui-secondary-color, 0.5));

View File

@ -254,10 +254,8 @@ $content-width: 840px;
&__actions { &__actions {
display: inline-flex; display: inline-flex;
flex-flow: wrap;
& > :not(:first-child) { gap: 5px;
margin-left: 5px;
}
} }
h2 small { h2 small {
@ -1590,6 +1588,15 @@ a.sparkline {
margin-bottom: 0; margin-bottom: 0;
} }
} }
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
} }
&__actions { &__actions {

View File

@ -533,14 +533,22 @@
&__tabs { &__tabs {
display: flex; display: flex;
align-items: flex-start; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
padding: 7px 10px; padding: 7px 10px;
margin-top: -55px; margin-top: -81px;
gap: 8px; height: 130px;
overflow: hidden; overflow: hidden;
margin-left: -2px; // aligns the pfp with content below margin-left: -2px; // aligns the pfp with content below
.account-role {
margin: 0 2px;
padding: 4px 0;
box-sizing: border-box;
min-width: 90px;
text-align: center;
}
&__buttons { &__buttons {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -118,7 +118,7 @@
&.active { &.active {
border-color: $highlight-text-color; border-color: $highlight-text-color;
background: $highlight-text-color; background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
} }
} }
} }
@ -590,7 +590,6 @@
} }
.privacy-dropdown__dropdown { .privacy-dropdown__dropdown {
position: absolute;
border-radius: 4px; border-radius: 4px;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
background: $simple-background-color; background: $simple-background-color;
@ -657,7 +656,6 @@
.language-dropdown { .language-dropdown {
&__dropdown { &__dropdown {
position: absolute;
background: $simple-background-color; background: $simple-background-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
border-radius: 4px; border-radius: 4px;

View File

@ -13,7 +13,7 @@
.emoji-picker-dropdown__menu { .emoji-picker-dropdown__menu {
background: $simple-background-color; background: $simple-background-color;
position: absolute; position: relative;
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
border-radius: 4px; border-radius: 4px;
margin-top: 5px; margin-top: 5px;

View File

@ -346,9 +346,8 @@
} }
} }
.dropdown-menu { body > [data-popper-placement] {
position: absolute; z-index: 3;
transform-origin: 50% 0;
} }
.invisible { .invisible {
@ -532,6 +531,42 @@
} }
} }
.dropdown-animation {
animation: dropdown 300ms cubic-bezier(0.1, 0.7, 0.1, 1);
@keyframes dropdown {
from {
opacity: 0;
transform: scaleX(0.85) scaleY(0.75);
}
to {
opacity: 1;
transform: scaleX(1) scaleY(1);
}
}
&.top {
transform-origin: bottom;
}
&.right {
transform-origin: left;
}
&.bottom {
transform-origin: top;
}
&.left {
transform-origin: right;
}
.reduce-motion & {
animation: none;
}
}
.dropdown { .dropdown {
display: inline-block; display: inline-block;
} }
@ -600,36 +635,42 @@
.dropdown-menu__arrow { .dropdown-menu__arrow {
position: absolute; position: absolute;
width: 0;
height: 0;
border: 0 solid transparent;
&.left { &::before {
right: -5px; content: '';
margin-top: -5px; display: block;
border-width: 5px 0 5px 5px; width: 14px;
border-left-color: $ui-secondary-color; height: 5px;
background-color: $ui-secondary-color;
mask-image: url("data:image/svg+xml;utf8,<svg width='14' height='5' xmlns='http://www.w3.org/2000/svg'><path d='M7 0L0 5h14L7 0z' fill='white'/></svg>");
} }
&.top { &.top {
bottom: -5px; bottom: -5px;
margin-left: -7px;
border-width: 5px 7px 0; &::before {
border-top-color: $ui-secondary-color; transform: rotate(180deg);
}
}
&.right {
left: -9px;
&::before {
transform: rotate(-90deg);
}
} }
&.bottom { &.bottom {
top: -5px; top: -5px;
margin-left: -7px;
border-width: 0 7px 5px;
border-bottom-color: $ui-secondary-color;
} }
&.right { &.left {
left: -5px; right: -9px;
margin-top: -5px;
border-width: 5px 5px 5px 0; &::before {
border-right-color: $ui-secondary-color; transform: rotate(90deg);
}
} }
} }
@ -1544,14 +1585,14 @@ button.icon-button.active i.fa-retweet {
align-items: center; align-items: center;
background: rgba($base-overlay-background, 0.8); background: rgba($base-overlay-background, 0.8);
display: flex; display: flex;
height: 100%; height: 100vh;
justify-content: center; justify-content: center;
left: 0; left: 0;
opacity: 0; opacity: 0;
position: absolute; position: fixed;
top: 0; top: 0;
visibility: hidden; visibility: hidden;
width: 100%; width: 100vw;
z-index: 2000; z-index: 2000;
* { * {

View File

@ -110,6 +110,10 @@
text-decoration: none; text-decoration: none;
} }
} }
#mastodon-settings--collapsed-auto-height {
width: calc(4ch + 20px);
}
} }
.glitch.local-settings__page__item.string, .glitch.local-settings__page__item.string,

View File

@ -37,7 +37,6 @@
.modal-root__modal { .modal-root__modal {
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
z-index: 9999;
} }
.media-modal__zoom-button { .media-modal__zoom-button {

View File

@ -157,6 +157,15 @@ code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
background: darken($ui-base-color, 12%); background: darken($ui-base-color, 12%);
} }
li {
list-style: disc;
margin-left: 18px;
}
}
ul.hint {
margin-bottom: 15px;
} }
span.hint { span.hint {

View File

@ -12,6 +12,14 @@ html {
&.button-alternative-2 { &.button-alternative-2 {
color: $white; color: $white;
} }
&.button-tertiary {
color: $highlight-text-color;
}
}
.simple_form .button.button-tertiary {
color: $highlight-text-color;
} }
.status-card__actions button, .status-card__actions button,
@ -286,22 +294,8 @@ html {
.dropdown-menu { .dropdown-menu {
background: $white; background: $white;
&__arrow { &__arrow::before {
&.left { background-color: $white;
border-left-color: $white;
}
&.top {
border-top-color: $white;
}
&.bottom {
border-bottom-color: $white;
}
&.right {
border-right-color: $white;
}
} }
&__item { &__item {
@ -558,25 +552,6 @@ html {
} }
} }
.directory__tag.active > a,
.directory__tag.active > div {
border-color: $ui-highlight-color;
&,
h4,
h4 small,
.fa,
.trends__item__current {
color: $white;
}
&:hover,
&:active,
&:focus {
background: $ui-highlight-color;
}
}
.batch-table { .batch-table {
&__toolbar, &__toolbar,
&__row, &__row,

View File

@ -117,7 +117,7 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 auto; flex: 0 0 auto;
margin-right: 10px; margin-inline-end: 10px;
top: -1px; top: -1px;
border-radius: 50%; border-radius: 50%;
vertical-align: middle; vertical-align: middle;
@ -212,7 +212,7 @@
.button { .button {
height: 36px; height: 36px;
padding: 0 16px; padding: 0 16px;
margin-right: 10px; margin-inline-end: 10px;
font-size: 14px; font-size: 14px;
} }
} }
@ -250,7 +250,7 @@
line-height: inherit; line-height: inherit;
color: $action-button-color; color: $action-button-color;
border-color: $action-button-color; border-color: $action-button-color;
margin-right: 5px; margin-inline-end: 5px;
} }
li { li {
@ -260,7 +260,7 @@
.poll__option { .poll__option {
flex: 0 0 auto; flex: 0 0 auto;
width: calc(100% - (23px + 6px)); width: calc(100% - (23px + 6px));
margin-right: 6px; margin-inline-end: 6px;
} }
} }
@ -289,10 +289,10 @@
color: $dark-text-color; color: $dark-text-color;
&__chart { &__chart {
background: rgba(darken($ui-primary-color, 14%), 0.2); background: rgba(darken($ui-primary-color, 14%), 0.7);
&.leading { &.leading {
background: rgba($ui-highlight-color, 0.2); background: rgba($ui-highlight-color, 0.5);
} }
} }
} }

View File

@ -12,10 +12,10 @@ pack:
home: home:
filename: packs/home.js filename: packs/home.js
preload: preload:
- flavours/glitch/async/compose - flavours/glitch/async/compose
- flavours/glitch/async/getting_started - flavours/glitch/async/getting_started
- flavours/glitch/async/home_timeline - flavours/glitch/async/home_timeline
- flavours/glitch/async/notifications - flavours/glitch/async/notifications
mailer: mailer:
modal: modal:
public: packs/public.js public: packs/public.js

View File

@ -12,10 +12,10 @@ pack:
home: home:
filename: application.js filename: application.js
preload: preload:
- features/getting_started - features/getting_started
- features/compose - features/compose
- features/home_timeline - features/home_timeline
- features/notifications - features/notifications
mailer: mailer:
modal: modal:
public: public.js public: public.js

View File

@ -160,6 +160,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
// If we're editing a post with media attachments, those have not
// necessarily been changed on the server. Do it now in the same
// API call.
let media_attributes;
if (statusId !== null) {
media_attributes = media.map(item => ({
id: item.get('id'),
description: item.get('description'),
focus: item.get('focus'),
}));
}
api(getState).request({ api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put', method: statusId === null ? 'post' : 'put',
@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
status, status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')), media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, params).then(response => { let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => { // Editing already-attached media is deferred to editing the post itself.
dispatch(changeUploadComposeFail(id, error)); // For simplicity's sake, fake an API reply.
}); if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
}
dispatch(changeUploadComposeSuccess(data, true));
} else {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data, false));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
}
}; };
} }
@ -392,10 +425,11 @@ export function changeUploadComposeRequest() {
}; };
} }
export function changeUploadComposeSuccess(media) { export function changeUploadComposeSuccess(media, attached) {
return { return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media, media: media,
attached: attached,
skipLoading: true, skipLoading: true,
}; };
} }

View File

@ -1,8 +1,8 @@
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
export function openDropdownMenu(id, placement, keyboard, scroll_key) { export function openDropdownMenu(id, keyboard, scroll_key) {
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key }; return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key };
} }
export function closeDropdownMenu(id) { export function closeDropdownMenu(id) {

View File

@ -1,9 +1,17 @@
import api from '../api'; import api, { getLinks } from '../api';
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
error, error,
}); });
export const fetchFollowedHashtags = () => (dispatch, getState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
};
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
};
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
};
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
};
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
};
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
};
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
};
export const followHashtag = name => (dispatch, getState) => { export const followHashtag = name => (dispatch, getState) => {
dispatch(followHashtagRequest(name)); dispatch(followHashtagRequest(name));

View File

@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
<Hashtag <Hashtag
key={hashtag.name} key={hashtag.name}
name={hashtag.name} name={hashtag.name}
to={`/admin/tags/${hashtag.id}`} to={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1} people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1} uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)} history={hashtag.history.reverse().map(day => day.uses)}

View File

@ -50,6 +50,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
id: PropTypes.string, id: PropTypes.string,
searchTokens: PropTypes.arrayOf(PropTypes.string), searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number, maxLength: PropTypes.number,
lang: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -185,7 +186,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
return ( return (
@ -210,6 +211,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
id={id} id={id}
className={className} className={className}
maxLength={maxLength} maxLength={maxLength}
lang={lang}
/> />
</label> </label>

View File

@ -48,6 +48,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
lang: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -192,7 +193,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
return [ return [
@ -216,6 +217,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onPaste={this.onPaste} onPaste={this.onPaste}
dir='auto' dir='auto'
aria-autocomplete='list' aria-autocomplete='list'
lang={lang}
/> />
</label> </label>
</div> </div>

View File

@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { CircularProgress } from 'mastodon/components/loading_indicator'; import { CircularProgress } from 'mastodon/components/loading_indicator';
@ -24,9 +22,6 @@ class DropdownMenu extends React.PureComponent {
scrollable: PropTypes.bool, scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
renderHeader: PropTypes.func, renderHeader: PropTypes.func,
@ -35,11 +30,6 @@ class DropdownMenu extends React.PureComponent {
static defaultProps = { static defaultProps = {
style: {}, style: {},
placement: 'bottom',
};
state = {
mounted: false,
}; };
handleDocumentClick = e => { handleDocumentClick = e => {
@ -56,8 +46,6 @@ class DropdownMenu extends React.PureComponent {
if (this.focusedItem && this.props.openedViaKeyboard) { if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true }); this.focusedItem.focus({ preventScroll: true });
} }
this.setState({ mounted: true });
} }
componentWillUnmount () { componentWillUnmount () {
@ -139,40 +127,28 @@ class DropdownMenu extends React.PureComponent {
} }
render () { render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props; const { items, scrollable, renderHeader, loading } = this.props;
const { mounted } = this.state;
let renderItem = this.props.renderItem || this.renderItem; let renderItem = this.props.renderItem || this.renderItem;
return ( return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{({ opacity, scaleX, scaleY }) => ( {loading && (
// It should not be transformed when mounting because the resulting <CircularProgress size={30} strokeWidth={3.5} />
// size will be used to determine the coordinate of the menu by )}
// react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}> {!loading && renderHeader && (
{loading && ( <div className='dropdown-menu__container__header'>
<CircularProgress size={30} strokeWidth={3.5} /> {renderHeader(items)}
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
</div> </div>
)} )}
</Motion>
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
); );
} }
@ -197,7 +173,6 @@ export default class Dropdown extends React.PureComponent {
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired, onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number, openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
@ -213,13 +188,11 @@ export default class Dropdown extends React.PureComponent {
id: id++, id: id++,
}; };
handleClick = ({ target, type }) => { handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) { if (this.state.id === this.props.openDropdownId) {
this.handleClose(); this.handleClose();
} else { } else {
const { top } = target.getBoundingClientRect(); this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
} }
} }
@ -303,7 +276,6 @@ export default class Dropdown extends React.PureComponent {
disabled, disabled,
loading, loading,
scrollable, scrollable,
dropdownPlacement,
openDropdownId, openDropdownId,
openedViaKeyboard, openedViaKeyboard,
children, children,
@ -314,7 +286,6 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId; const open = this.state.id === openDropdownId;
const button = children ? React.cloneElement(React.Children.only(children), { const button = children ? React.cloneElement(React.Children.only(children), {
ref: this.setTargetRef,
onClick: this.handleClick, onClick: this.handleClick,
onMouseDown: this.handleMouseDown, onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown, onKeyDown: this.handleButtonKeyDown,
@ -326,7 +297,6 @@ export default class Dropdown extends React.PureComponent {
active={open} active={open}
disabled={disabled} disabled={disabled}
size={size} size={size}
ref={this.setTargetRef}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown} onKeyDown={this.handleButtonKeyDown}
@ -336,19 +306,27 @@ export default class Dropdown extends React.PureComponent {
return ( return (
<React.Fragment> <React.Fragment>
{button} <span ref={this.setTargetRef}>
{button}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> </span>
<DropdownMenu <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
items={items} {({ props, arrowProps, placement }) => (
loading={loading} <div {...props}>
scrollable={scrollable} <div className={`dropdown-animation dropdown-menu ${placement}`}>
onClose={this.handleClose} <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
openedViaKeyboard={openedViaKeyboard} <DropdownMenu
renderItem={renderItem} items={items}
renderHeader={renderHeader} loading={loading}
onItemClick={this.handleItemClick} scrollable={scrollable}
/> onClose={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</React.Fragment> </React.Fragment>
); );

View File

@ -4,7 +4,6 @@ import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu'; import DropdownMenu from 'mastodon/components/dropdown_menu';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
items: state.getIn(['history', statusId, 'items']), items: state.getIn(['history', statusId, 'items']),
@ -13,9 +12,9 @@ const mapStateToProps = (state, { statusId }) => ({
const mapDispatchToProps = (dispatch, { statusId }) => ({ const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, dropdownPlacement, keyboard) { onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId)); dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu(id, dropdownPlacement, keyboard)); dispatch(openDropdownMenu(id, keyboard));
}, },
onClose (id) { onClose (id) {

View File

@ -27,6 +27,7 @@ export default class IconButton extends React.PureComponent {
counter: PropTypes.number, counter: PropTypes.number,
obfuscateCount: PropTypes.bool, obfuscateCount: PropTypes.bool,
href: PropTypes.string, href: PropTypes.string,
ariaHidden: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -36,6 +37,7 @@ export default class IconButton extends React.PureComponent {
animate: false, animate: false,
overlay: false, overlay: false,
tabIndex: '0', tabIndex: '0',
ariaHidden: false,
}; };
state = { state = {
@ -102,6 +104,7 @@ export default class IconButton extends React.PureComponent {
counter, counter,
obfuscateCount, obfuscateCount,
href, href,
ariaHidden,
} = this.props; } = this.props;
const { const {
@ -142,6 +145,7 @@ export default class IconButton extends React.PureComponent {
type='button' type='button'
aria-label={title} aria-label={title}
aria-expanded={expanded} aria-expanded={expanded}
aria-hidden={ariaHidden}
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}

View File

@ -345,7 +345,7 @@ class MediaGallery extends React.PureComponent {
</button> </button>
); );
} else if (visible) { } else if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />; spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />;
} else { } else {
spoilerButton = ( spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>

Some files were not shown because too many files have changed in this diff Show More