Merge branch 'main' into glitch-soc/merge-upstream

rebase/4.0.0rc1
Claire 2022-10-28 11:36:25 +02:00
commit cb19be67d1
567 changed files with 14361 additions and 20828 deletions

View File

@ -31,6 +31,11 @@ body:
description: What happened? description: What happened?
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Detailed description
validations:
required: false
- type: textarea - type: textarea
attributes: attributes:
label: Specifications label: Specifications
@ -38,5 +43,14 @@ body:
What version or commit hash of Mastodon did you find this bug in? What version or commit hash of Mastodon did you find this bug in?
If a front-end issue, what browser and operating systems were you using? If a front-end issue, what browser and operating systems were you using?
placeholder: |
Mastodon 3.5.3 (or Edge)
Ruby 2.7.6 (or v3.1.2)
Node.js 16.18.0
Google Chrome 106.0.5249.119
Firefox 105.0.3
etc...
validations: validations:
required: true required: true

View File

@ -34,7 +34,8 @@ jobs:
latest=auto latest=auto
tags: | tags: |
type=edge,branch=main type=edge,branch=main
type=match,pattern=v(.*),group=0 type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}
type=ref,event=pr type=ref,event=pr
- uses: docker/build-push-action@v3 - uses: docker/build-push-action@v3
with: with:
@ -42,5 +43,5 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:latest cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:edge
cache-to: type=inline cache-to: type=inline

View File

@ -3,6 +3,132 @@ 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.
## [Unreleased]
Some of the features in this release have been funded through the [NGI0 Discovery](https://nlnet.nl/discovery) Fund, a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 825322.
### Added
- Add ability to filter followed accounts' posts by language ([Gargron](https://github.com/mastodon/mastodon/pull/19095), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19268))
- **Add ability to follow hashtags** ([Gargron](https://github.com/mastodon/mastodon/pull/18809), [Gargron](https://github.com/mastodon/mastodon/pull/18862), [Gargron](https://github.com/mastodon/mastodon/pull/19472), [noellabo](https://github.com/mastodon/mastodon/pull/18924))
- Add ability to filter individual posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18945))
- **Add ability to translate posts** ([Gargron](https://github.com/mastodon/mastodon/pull/19218), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19433), [Gargron](https://github.com/mastodon/mastodon/pull/19453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19434), [Gargron](https://github.com/mastodon/mastodon/pull/19388), [ykzts](https://github.com/mastodon/mastodon/pull/19244), [Gargron](https://github.com/mastodon/mastodon/pull/19245))
- Add featured tags to web UI ([noellabo](https://github.com/mastodon/mastodon/pull/19408), [noellabo](https://github.com/mastodon/mastodon/pull/19380), [noellabo](https://github.com/mastodon/mastodon/pull/19358), [noellabo](https://github.com/mastodon/mastodon/pull/19409), [Gargron](https://github.com/mastodon/mastodon/pull/19382), [ykzts](https://github.com/mastodon/mastodon/pull/19418), [noellabo](https://github.com/mastodon/mastodon/pull/19403), [noellabo](https://github.com/mastodon/mastodon/pull/19404), [Gargron](https://github.com/mastodon/mastodon/pull/19398))
- **Add support for language preferences for trending statuses and links** ([Gargron](https://github.com/mastodon/mastodon/pull/18288), [Gargron](https://github.com/mastodon/mastodon/pull/19349), [ykzts](https://github.com/mastodon/mastodon/pull/19335))
- Previously, you could only see trends in your current language
- For less popular languages, that meant empty trends
- Now, trends in your preferred languages' are shown on top, with others beneath
- Add server rules to sign-up flow ([Gargron](https://github.com/mastodon/mastodon/pull/19296))
- Add privacy icons to report modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19190))
- Add `noopener` to links to remote profiles in web UI ([shleeable](https://github.com/mastodon/mastodon/pull/19014))
- Add warning for sensitive audio posts in web UI ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/17885))
- Add language attribute to posts in web UI ([tribela](https://github.com/mastodon/mastodon/pull/18544))
- Add meta tag for official iOS app ([Gargron](https://github.com/mastodon/mastodon/pull/16599))
- Add support for uploading WebP files ([Saiv46](https://github.com/mastodon/mastodon/pull/18506))
- Add support for uploading `audio/vnd.wave` files ([tribela](https://github.com/mastodon/mastodon/pull/18737))
- Add more debug information when processing remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15605), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19209))
- **Add retention policy for cached content and media** ([Gargron](https://github.com/mastodon/mastodon/pull/19232), [zunda](https://github.com/mastodon/mastodon/pull/19478), [Gargron](https://github.com/mastodon/mastodon/pull/19458), [Gargron](https://github.com/mastodon/mastodon/pull/19248))
- Set for how long remote posts or media should be cached on your server
- Hands-off alternative to `tootctl` commands
- **Add customizable user roles** ([Gargron](https://github.com/mastodon/mastodon/pull/18641), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18812), [Gargron](https://github.com/mastodon/mastodon/pull/19040), [tribela](https://github.com/mastodon/mastodon/pull/18825), [tribela](https://github.com/mastodon/mastodon/pull/18826), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18776), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18777), [unextro](https://github.com/mastodon/mastodon/pull/18786), [tribela](https://github.com/mastodon/mastodon/pull/18824), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19436))
- Previously, there were 3 hard-coded roles, user, moderator, and admin
- Create your own roles and decide which permissions they should have
- Add notifications for new reports ([Gargron](https://github.com/mastodon/mastodon/pull/18697), [Gargron](https://github.com/mastodon/mastodon/pull/19475))
- Add ability to select all accounts matching search for batch actions in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19053), [Gargron](https://github.com/mastodon/mastodon/pull/19054))
- Add ability to view previous edits of a status in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19462))
- Add ability to block sign-ups from IP ([Gargron](https://github.com/mastodon/mastodon/pull/19037))
- **Add webhooks to admin UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18510))
- Add admin API for managing domain allows ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18668))
- Add admin API for managing domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18247))
- Add admin API for managing e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19066))
- Add admin API for managing canonical e-mail blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19067))
- Add admin API for managing IP blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19065))
- Add `services` and `metadata` to the NodeInfo endpoint ([MFTabriz](https://github.com/mastodon/mastodon/pull/18563))
- Add `--remove-role` option to `tootctl accounts modify` ([Gargron](https://github.com/mastodon/mastodon/pull/19477))
- Add `--days` option to `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/18425))
- Add `EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18642))
- Add `IP_RETENTION_PERIOD` and `SESSION_RETENTION_PERIOD` environment variables ([kescherCode](https://github.com/mastodon/mastodon/pull/18757))
- Add `http_hidden_proxy` environment variable ([tribela](https://github.com/mastodon/mastodon/pull/18427))
### Changed
- **Change brand color and logotypes** ([Gargron](https://github.com/mastodon/mastodon/pull/18592), [Gargron](https://github.com/mastodon/mastodon/pull/18639), [Gargron](https://github.com/mastodon/mastodon/pull/18691), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18634), [Gargron](https://github.com/mastodon/mastodon/pull/19254), [mayaeh](https://github.com/mastodon/mastodon/pull/18710))
- **Change post editing to be enabled in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/19103))
- **Change web UI to work for logged-out users** ([Gargron](https://github.com/mastodon/mastodon/pull/18961), [Gargron](https://github.com/mastodon/mastodon/pull/19250), [Gargron](https://github.com/mastodon/mastodon/pull/19294), [Gargron](https://github.com/mastodon/mastodon/pull/19306), [Gargron](https://github.com/mastodon/mastodon/pull/19315), [ykzts](https://github.com/mastodon/mastodon/pull/19322), [Gargron](https://github.com/mastodon/mastodon/pull/19412), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19437), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19415), [Gargron](https://github.com/mastodon/mastodon/pull/19348), [Gargron](https://github.com/mastodon/mastodon/pull/19295), [Gargron](https://github.com/mastodon/mastodon/pull/19422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19414), [Gargron](https://github.com/mastodon/mastodon/pull/19319), [Gargron](https://github.com/mastodon/mastodon/pull/19345), [Gargron](https://github.com/mastodon/mastodon/pull/19310), [Gargron](https://github.com/mastodon/mastodon/pull/19301), [Gargron](https://github.com/mastodon/mastodon/pull/19423), [ykzts](https://github.com/mastodon/mastodon/pull/19471), [ykzts](https://github.com/mastodon/mastodon/pull/19333), [ykzts](https://github.com/mastodon/mastodon/pull/19337), [ykzts](https://github.com/mastodon/mastodon/pull/19272), [ykzts](https://github.com/mastodon/mastodon/pull/19468), [Gargron](https://github.com/mastodon/mastodon/pull/19466), [Gargron](https://github.com/mastodon/mastodon/pull/19457), [Gargron](https://github.com/mastodon/mastodon/pull/19426), [Gargron](https://github.com/mastodon/mastodon/pull/19427), [Gargron](https://github.com/mastodon/mastodon/pull/19421), [Gargron](https://github.com/mastodon/mastodon/pull/19417), [Gargron](https://github.com/mastodon/mastodon/pull/19413), [Gargron](https://github.com/mastodon/mastodon/pull/19397), [Gargron](https://github.com/mastodon/mastodon/pull/19387), [Gargron](https://github.com/mastodon/mastodon/pull/19396), [Gargron](https://github.com/mastodon/mastodon/pull/19385), [ykzts](https://github.com/mastodon/mastodon/pull/19334), [ykzts](https://github.com/mastodon/mastodon/pull/19329), [Gargron](https://github.com/mastodon/mastodon/pull/19324), [Gargron](https://github.com/mastodon/mastodon/pull/19318), [Gargron](https://github.com/mastodon/mastodon/pull/19316), [Gargron](https://github.com/mastodon/mastodon/pull/19263), [trwnh](https://github.com/mastodon/mastodon/pull/19305), [ykzts](https://github.com/mastodon/mastodon/pull/19273))
- The web app can now be accessed without being logged in
- No more `/web` prefix on web app paths
- Profiles, posts, and other public pages now use the same interface for logged in and logged out users
- The web app displays a server information banner
- Pop-up windows for remote interaction have been replaced with a modal window
- No need to type in your username for remote interaction, copy-paste-to-search method explained
- Various hints throughout the app explain what the different timelines are
- New about page design
- New privacy policy page design shows when the policy was last updated
- All sections of the web app now have appropriate window titles
- The layout of the interface has been streamlined between different screen sizes
- Posts now use more horizontal space
- Change label of publish button to be "Publish" again in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18583))
- Change language to be carried over on reply in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18557))
- Change "Unfollow" to "Cancel follow request" when request still pending in web UI ([prplecake](https://github.com/mastodon/mastodon/pull/19363))
- **Change post filtering system** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18058), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19050), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18894), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19051), [noellabo](https://github.com/mastodon/mastodon/pull/18923), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18744))
- Filtered keywords and phrases can now be grouped into named categories
- Filtered posts show which exact filter was hit
- Individual posts can be added to a filter
- You can peek inside filtered posts anyway
- Change path of privacy policy page from `/terms` to `/privacy-policy` ([Gargron](https://github.com/mastodon/mastodon/pull/19249))
- Change how hashtags are normalized ([Gargron](https://github.com/mastodon/mastodon/pull/18795), [Gargron](https://github.com/mastodon/mastodon/pull/18863), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18854))
- Change public timelines to be filtered by current locale by default ([Gargron](https://github.com/mastodon/mastodon/pull/19291))
- Change settings area to be separated into categories in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19407))
- Change "No accounts selected" errors to use the appropriate noun in admin UI ([prplecake](https://github.com/mastodon/mastodon/pull/19356))
- Change e-mail domain blocks to match subdomains of blocked domains ([Gargron](https://github.com/mastodon/mastodon/pull/18979))
- Change custom emoji file size limit from 50 KB to 256 KB ([Gargron](https://github.com/mastodon/mastodon/pull/18788))
- Change "Allow trends without prior review" setting to also work for trending posts ([Gargron](https://github.com/mastodon/mastodon/pull/17977))
- Change search API to be accessible without being logged in ([Gargron](https://github.com/mastodon/mastodon/pull/18963), [Gargron](https://github.com/mastodon/mastodon/pull/19326))
- Change following and followers API to be accessible without being logged in ([Gargron](https://github.com/mastodon/mastodon/pull/18964))
- Change Helm configuration ([deepy](https://github.com/mastodon/mastodon/pull/18997), [jgsmith](https://github.com/mastodon/mastodon/pull/18415), [deepy](https://github.com/mastodon/mastodon/pull/18941))
### Removed
- Remove setting that disables account deletes ([Gargron](https://github.com/mastodon/mastodon/pull/17683))
- Remove digest e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/17985))
- Remove unnecessary sections from welcome e-mail ([Gargron](https://github.com/mastodon/mastodon/pull/19299))
- Remove item titles from RSS feeds ([Gargron](https://github.com/mastodon/mastodon/pull/18640))
- Remove volume number from hashtags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19253))
- Remove Nanobox configuration ([tonyjiang](https://github.com/mastodon/mastodon/pull/17881))
### Fixed
- Fix OCR not working due to Content Security Policy in web UI ([prplecake](https://github.com/mastodon/mastodon/pull/18817))
- Fix `nofollow` rel being removed in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19455))
- Fix language dropdown causing zoom on mobile devices in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19428))
- Fix button to dismiss suggestions not showing up in search results in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19325))
- Fix language dropdown sometimes not appearing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19246))
- Fix quickly switching notification filters resulting in empty or incorrect list in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19052), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18960))
- Fix media modal link button in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18877))
- Fix error upon successful account migration ([Gargron](https://github.com/mastodon/mastodon/pull/19386))
- Fix negatives values in search index causing queries to fail ([Gargron](https://github.com/mastodon/mastodon/pull/19464), [Gargron](https://github.com/mastodon/mastodon/pull/19481))
- Fix error when searching for invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18580))
- Fix IP blocks not having a unique index ([Gargron](https://github.com/mastodon/mastodon/pull/19456))
- Fix remote account in contact account setting not being used ([Gargron](https://github.com/mastodon/mastodon/pull/19351))
- Fix swallowing mentions of unconfirmed/unapproved users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19191))
- Fix incorrect and slow cache invalidation when blocking domain and removing media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19062))
- Fix HTTPs redirect behaviour when running as I2P service ([gi-yt](https://github.com/mastodon/mastodon/pull/18929))
- Fix deleted pinned posts potentially counting towards the pinned posts limit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19005))
- Fix compatibility with OpenSSL 3.0 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18449))
- Fix error when a remote report includes a private post the server has no access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18760))
- Fix suspicious sign-in mails never being sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18599))
- Fix fallback locale when somehow user's locale is an empty string ([tribela](https://github.com/mastodon/mastodon/pull/18543))
- Fix avatar/header not being deleted locally when deleted on remote account ([tribela](https://github.com/mastodon/mastodon/pull/18973))
- Fix missing `,` in Blurhash validation ([noellabo](https://github.com/mastodon/mastodon/pull/18660))
- Fix order by most recent not working for relationships page in admin UI ([tribela](https://github.com/mastodon/mastodon/pull/18996))
- Fix uncaught error when invalid date is supplied to API ([Gargron](https://github.com/mastodon/mastodon/pull/19480))
- Fix REST API sometimes returning HTML on error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19135))
- Fix ambiguous column names in `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/19206))
- Fix ambiguous column names in `tootctl search deploy` ([mashirozx](https://github.com/mastodon/mastodon/pull/18993))
- Fix `CDN_HOST` not being used in some asset URLs ([tribela](https://github.com/mastodon/mastodon/pull/18662))
- Fix `CAS_DISPLAY_NAME`, `SAML_DISPLAY_NAME` and `OIDC_DISPLAY_NAME` being ignored ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18568))
- Fix various typos in comments throughout the codebase ([luzpaz](https://github.com/mastodon/mastodon/pull/18604))
## [3.5.3] - 2022-05-26 ## [3.5.3] - 2022-05-26
### Added ### Added
@ -75,7 +201,7 @@ All notable changes to this project will be documented in this file.
- Remove IP matching from e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/18190)) - Remove IP matching from e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/18190))
- The IPs of the blocked e-mail domain or its MX records are no longer checked - The IPs of the blocked e-mail domain or its MX records are no longer checked
- Previously it was too easy to block e-mail providers by mistake - Previously it was too easy to block e-mail providers by mistake
## Fixed ## Fixed
- Fix compatibility with Friendica's pinned posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18254), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18260)) - Fix compatibility with Friendica's pinned posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18254), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18260))

View File

@ -72,6 +72,7 @@ gem 'rack-attack', '~> 6.6'
gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 6.0' gem 'rails-i18n', '~> 6.0'
gem 'rails-settings-cached', '~> 0.6' gem 'rails-settings-cached', '~> 0.6'
gem 'redcarpet', '~> 3.5'
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 2.1' gem 'rqrcode', '~> 2.1'
@ -98,8 +99,6 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2' gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5' gem 'rdf-normalize', '~> 0.5'
gem 'redcarpet', '~> 3.5'
group :development, :test do group :development, :test do
gem 'fabrication', '~> 2.30' gem 'fabrication', '~> 2.30'
gem 'fuubar', '~> 2.5' gem 'fuubar', '~> 2.5'

View File

@ -272,7 +272,7 @@ GEM
fog-json (>= 1.0) fog-json (>= 1.0)
ipaddress (>= 0.8) ipaddress (>= 0.8)
formatador (0.2.5) formatador (0.2.5)
fugit (1.5.3) fugit (1.7.1)
et-orbi (~> 1, >= 1.2.7) et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4) raabro (~> 1.4)
fuubar (2.5.1) fuubar (2.5.1)
@ -600,7 +600,7 @@ GEM
nokogiri (>= 1.10.5) nokogiri (>= 1.10.5)
rexml rexml
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rufus-scheduler (3.8.1) rufus-scheduler (3.8.2)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
@ -617,10 +617,10 @@ GEM
redis (>= 4.5.0, < 5) redis (>= 4.5.0, < 5)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (4.0.2) sidekiq-scheduler (4.0.3)
redis (>= 4.2.0) redis (>= 4.2.0)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 4) sidekiq (>= 4, < 7)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.27) sidekiq-unique-jobs (7.1.27)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0) brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
@ -649,7 +649,7 @@ GEM
sshkit (1.21.2) sshkit (1.21.2)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.21) stackprof (0.2.22)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stoplight (3.0.0) stoplight (3.0.0)
strong_migrations (0.7.9) strong_migrations (0.7.9)
@ -664,7 +664,7 @@ GEM
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
thor (1.2.1) thor (1.2.1)
tilt (2.0.10) tilt (2.0.11)
tpm-key_attestation (0.11.0) tpm-key_attestation (0.11.0)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0, < 3.1) openssl (> 2.0, < 3.1)

View File

@ -1,72 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class AboutController < ApplicationController class AboutController < ApplicationController
include RegistrationSpamConcern include WebAppControllerConcern
before_action :set_pack skip_before_action :require_functional!
layout 'public'
before_action :require_open_federation!, only: [:show, :more]
before_action :set_body_classes, only: :show
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_expires_in, only: [:more]
before_action :set_registration_form_time, only: :show
skip_before_action :require_functional!, only: [:more] def show
expires_in 0, public: true unless user_signed_in?
def show; end
def more
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
toc_generator = TOCGenerator.new(@instance_presenter.extended_description)
@rules = Rule.ordered
@contents = toc_generator.html
@table_of_contents = toc_generator.toc
@blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks?
end end
helper_method :display_blocks?
helper_method :display_blocks_rationale?
helper_method :public_fetch_mode?
helper_method :new_user
private private
def require_open_federation!
not_found if whitelist_mode?
end
def display_blocks?
Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
end
def display_blocks_rationale?
Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)
end
def new_user
User.new.tap do |user|
user.build_account
user.build_invite_request
end
end
def set_pack
use_pack 'public'
end
def set_instance_presenter def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end
def set_body_classes
@hide_navbar = true
end
def set_expires_in
expires_in 0, public: true
end
end end

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
class AccountFollowController < ApplicationController
include AccountControllerConcern
before_action :authenticate_user!
def create
FollowService.new.call(current_user.account, @account, with_rate_limit: true)
redirect_to account_path(@account)
end
end

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
class AccountUnfollowController < ApplicationController
include AccountControllerConcern
before_action :authenticate_user!
def create
UnfollowService.new.call(current_user.account, @account)
redirect_to account_path(@account)
end
end

View File

@ -9,7 +9,6 @@ class AccountsController < ApplicationController
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) } skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
skip_before_action :require_functional!, unless: :whitelist_mode? skip_before_action :require_functional!, unless: :whitelist_mode?
@ -17,26 +16,7 @@ class AccountsController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
use_pack 'public'
expires_in 0, public: true unless user_signed_in? expires_in 0, public: true unless user_signed_in?
@pinned_statuses = []
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
@featured_hashtags = @account.featured_tags.order(statuses_count: :desc)
if current_account && @account.blocking?(current_account)
@statuses = []
return
end
@pinned_statuses = cached_filtered_status_pins if show_pinned_statuses?
@statuses = cached_filtered_status_page
@rss_url = rss_url
unless @statuses.empty?
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
@newer_url = newer_url if @statuses.first.id < filtered_statuses.first.id
end
end end
format.rss do format.rss do
@ -56,18 +36,6 @@ class AccountsController < ApplicationController
private private
def set_body_classes
@body_classes = 'with-modals'
end
def show_pinned_statuses?
[replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
end
def filtered_pinned_statuses
@account.pinned_statuses.not_local_only.where(visibility: [:public, :unlisted])
end
def filtered_statuses def filtered_statuses
default_statuses.tap do |statuses| default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested? statuses.merge!(hashtag_scope) if tag_requested?
@ -114,26 +82,6 @@ class AccountsController < ApplicationController
end end
end end
def older_url
pagination_url(max_id: @statuses.last.id)
end
def newer_url
pagination_url(min_id: @statuses.first.id)
end
def pagination_url(max_id: nil, min_id: nil)
if tag_requested?
short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
elsif media_requested?
short_account_media_url(@account, max_id: max_id, min_id: min_id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
else
short_account_url(@account, max_id: max_id, min_id: min_id)
end
end
def media_requested? def media_requested?
request.path.split('.').first.end_with?('/media') && !tag_requested? request.path.split('.').first.end_with?('/media') && !tag_requested?
end end
@ -146,13 +94,6 @@ class AccountsController < ApplicationController
request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end end
def cached_filtered_status_pins
cache_collection(
filtered_pinned_statuses,
Status
)
end
def cached_filtered_status_page def cached_filtered_status_page
cache_collection_paginated_by_id( cache_collection_paginated_by_id(
filtered_statuses, filtered_statuses,

View File

@ -34,7 +34,7 @@ module Admin
@form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.custom_emojis.no_emoji_selected')
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e

View File

@ -5,7 +5,7 @@ module Admin
def index def index
authorize :ip_block, :index? authorize :ip_block, :index?
@ip_blocks = IpBlock.page(params[:page]) @ip_blocks = IpBlock.order(ip: :asc).page(params[:page])
@form = Form::IpBlockBatch.new @form = Form::IpBlockBatch.new
end end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::AboutController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_about_path
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::AppearanceController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_appearance_path
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::BrandingController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_branding_path
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::ContentRetentionController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_content_retention_path
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::DiscoveryController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_discovery_path
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::RegistrationsController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_registrations_path
end
end

View File

@ -2,7 +2,7 @@
module Admin module Admin
class SettingsController < BaseController class SettingsController < BaseController
def edit def show
authorize :settings, :show? authorize :settings, :show?
@admin_settings = Form::AdminSettings.new @admin_settings = Form::AdminSettings.new
@ -15,14 +15,18 @@ module Admin
if @admin_settings.save if @admin_settings.save
flash[:notice] = I18n.t('generic.changes_saved_msg') flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to edit_admin_settings_path redirect_to after_update_redirect_path
else else
render :edit render :show
end end
end end
private private
def after_update_redirect_path
raise NotImplementedError
end
def settings_params def settings_params
params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS)
end end

View File

@ -9,7 +9,7 @@ module Admin
@site_upload.destroy! @site_upload.destroy!
redirect_to edit_admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') redirect_to admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg')
end end
private private

View File

@ -3,18 +3,23 @@
module Admin module Admin
class StatusesController < BaseController class StatusesController < BaseController
before_action :set_account before_action :set_account
before_action :set_statuses before_action :set_statuses, except: :show
before_action :set_status, only: :show
PER_PAGE = 20 PER_PAGE = 20
def index def index
authorize :status, :index? authorize [:admin, :status], :index?
@status_batch_action = Admin::StatusBatchAction.new @status_batch_action = Admin::StatusBatchAction.new
end end
def show
authorize [:admin, @status], :show?
end
def batch def batch
authorize :status, :index? authorize [:admin, :status], :index?
@status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
@status_batch_action.save! @status_batch_action.save!
@ -32,6 +37,7 @@ module Admin
def after_create_redirect_path def after_create_redirect_path
report_id = @status_batch_action&.report_id || params[:report_id] report_id = @status_batch_action&.report_id || params[:report_id]
if report_id.present? if report_id.present?
admin_report_path(report_id) admin_report_path(report_id)
else else
@ -43,6 +49,10 @@ module Admin
@account = Account.find(params[:account_id]) @account = Account.find(params[:account_id])
end end
def set_status
@status = @account.statuses.find(params[:id])
end
def set_statuses def set_statuses
@statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
end end

View File

@ -14,7 +14,7 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
@form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.trends.links.publishers.no_publisher_selected')
ensure ensure
redirect_to admin_trends_links_preview_card_providers_path(filter_params) redirect_to admin_trends_links_preview_card_providers_path(filter_params)
end end

View File

@ -4,6 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController
def index def index
authorize :preview_card, :review? authorize :preview_card, :review?
@locales = PreviewCardTrend.pluck('distinct language')
@preview_cards = filtered_preview_cards.page(params[:page]) @preview_cards = filtered_preview_cards.page(params[:page])
@form = Trends::PreviewCardBatch.new @form = Trends::PreviewCardBatch.new
end end
@ -14,7 +15,7 @@ class Admin::Trends::LinksController < Admin::BaseController
@form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.trends.links.no_link_selected')
ensure ensure
redirect_to admin_trends_links_path(filter_params) redirect_to admin_trends_links_path(filter_params)
end end

View File

@ -2,19 +2,20 @@
class Admin::Trends::StatusesController < Admin::BaseController class Admin::Trends::StatusesController < Admin::BaseController
def index def index
authorize :status, :review? authorize [:admin, :status], :review?
@locales = StatusTrend.pluck('distinct language')
@statuses = filtered_statuses.page(params[:page]) @statuses = filtered_statuses.page(params[:page])
@form = Trends::StatusBatch.new @form = Trends::StatusBatch.new
end end
def batch def batch
authorize :status, :review? authorize [:admin, :status], :review?
@form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.trends.statuses.no_status_selected')
ensure ensure
redirect_to admin_trends_statuses_path(filter_params) redirect_to admin_trends_statuses_path(filter_params)
end end

View File

@ -14,7 +14,7 @@ class Admin::Trends::TagsController < Admin::BaseController
@form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.trends.tags.no_tag_selected')
ensure ensure
redirect_to admin_trends_tags_path(filter_params) redirect_to admin_trends_tags_path(filter_params)
end end

View File

@ -24,6 +24,10 @@ class Api::BaseController < ApplicationController
render json: { error: 'Duplicate record' }, status: 422 render json: { error: 'Duplicate record' }, status: 422
end end
rescue_from Date::Error do
render json: { error: 'Invalid date supplied' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404 render json: { error: 'Record not found' }, status: 404
end end

View File

@ -13,12 +13,12 @@ class Api::V1::FeaturedTagsController < Api::BaseController
end end
def create def create
@featured_tag = current_account.featured_tags.create!(featured_tag_params) featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name])
render json: @featured_tag, serializer: REST::FeaturedTagSerializer render json: featured_tag, serializer: REST::FeaturedTagSerializer
end end
def destroy def destroy
@featured_tag.destroy! RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id)
render_empty render_empty
end end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Api::V1::Instances::DomainBlocksController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
before_action :require_enabled_api!
before_action :set_domain_blocks
def index
expires_in 3.minutes, public: true
render json: @domain_blocks, each_serializer: REST::DomainBlockSerializer, with_comment: (Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?))
end
private
def require_enabled_api!
head 404 unless Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
end
def set_domain_blocks
@domain_blocks = DomainBlock.with_user_facing_limitations.by_severity
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Instances::ExtendedDescriptionsController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
before_action :set_extended_description
def show
expires_in 3.minutes, public: true
render json: @extended_description, serializer: REST::ExtendedDescriptionSerializer
end
private
def set_extended_description
@extended_description = ExtendedDescription.current
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Instances::PrivacyPoliciesController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
before_action :set_privacy_policy
def show
expires_in 1.day, public: true
render json: @privacy_policy, serializer: REST::PrivacyPolicySerializer
end
private
def set_privacy_policy
@privacy_policy = PrivacyPolicy.current
end
end

View File

@ -79,6 +79,7 @@ class Api::V1::StatusesController < Api::BaseController
authorize @status, :destroy? authorize @status, :destroy?
@status.discard @status.discard
StatusPin.find_by(status: @status)&.destroy
@status.account.statuses_count = @status.account.statuses_count - 1 @status.account.statuses_count = @status.account.statuses_count - 1
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true

View File

@ -35,6 +35,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
def public_feed def public_feed
PublicFeed.new( PublicFeed.new(
current_account, current_account,
locale: content_locale,
local: truthy_param?(:local), local: truthy_param?(:local),
remote: truthy_param?(:remote), remote: truthy_param?(:remote),
only_media: truthy_param?(:only_media), only_media: truthy_param?(:only_media),

View File

@ -36,6 +36,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
TagFeed.new( TagFeed.new(
@tag, @tag,
current_account, current_account,
locale: content_locale,
any: params[:any], any: params[:any],
all: params[:all], all: params[:all],
none: params[:none], none: params[:none],

View File

@ -28,7 +28,9 @@ class Api::V1::Trends::LinksController < Api::BaseController
end end
def links_from_trends def links_from_trends
Trends.links.query.allowed.in_locale(content_locale) scope = Trends.links.query.allowed.in_locale(content_locale)
scope = scope.filtered_for(current_account) if user_signed_in?
scope
end end
def insert_pagination_headers def insert_pagination_headers

View File

@ -5,8 +5,8 @@ class Api::V2::SearchController < Api::BaseController
RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i
before_action -> { doorkeeper_authorize! :read, :'read:search' } before_action -> { authorize_if_got_token! :read, :'read:search' }
before_action :require_user! before_action :validate_search_params!
def index def index
@search = Search.new(search_results) @search = Search.new(search_results)
@ -19,6 +19,16 @@ class Api::V2::SearchController < Api::BaseController
private private
def validate_search_params!
params.require(:q)
return if user_signed_in?
return render json: { error: 'Search queries pagination is not supported without authentication' }, status: 401 if params[:offset].present?
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401 if truthy_param?(:resolve)
end
def search_results def search_results
SearchService.new.call( SearchService.new.call(
params[:q], params[:q],

View File

@ -15,6 +15,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update] before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update] before_action :set_cache_headers, only: [:edit, :update]
before_action :set_rules, only: :new
before_action :require_rules_acceptance!, only: :new
before_action :set_registration_form_time, only: :new before_action :set_registration_form_time, only: :new
skip_before_action :require_functional!, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update]
@ -56,7 +58,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def configure_sign_up_params def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u| devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) u.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
end end
end end
@ -143,6 +145,18 @@ class Auth::RegistrationsController < Devise::RegistrationsController
forbidden if current_account.suspended? forbidden if current_account.suspended?
end end
def set_rules
@rules = Rule.ordered
end
def require_rules_acceptance!
return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token])
@accept_token = session[:accept_token] = SecureRandom.hex
set_locale { render :rules }
end
def set_cache_headers def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end end

View File

@ -3,13 +3,12 @@
module AccountControllerConcern module AccountControllerConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
include WebAppControllerConcern
include AccountOwnedConcern include AccountOwnedConcern
FOLLOW_PER_PAGE = 12 FOLLOW_PER_PAGE = 12
included do included do
layout 'public'
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html } before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html }
end end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module WebAppControllerConcern
extend ActiveSupport::Concern
included do
before_action :set_pack
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_app_body_class
before_action :set_referrer_policy_header
end
def set_app_body_class
@body_classes = 'app-body'
end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin'
end
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
redirect_path = PermalinkRedirector.new(request.path).redirect_path
redirect_to(redirect_path) if redirect_path.present?
end
def set_pack
use_pack 'home'
end
end

View File

@ -1,37 +0,0 @@
# frozen_string_literal: true
class DirectoriesController < ApplicationController
layout 'public'
before_action :authenticate_user!, if: :whitelist_mode?
before_action :require_enabled!
before_action :set_instance_presenter
before_action :set_accounts
before_action :set_pack
skip_before_action :require_functional!, unless: :whitelist_mode?
def index
render :index
end
private
def set_pack
use_pack 'share'
end
def require_enabled!
return not_found unless Setting.profile_directory
end
def set_accounts
@accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
query.merge!(Account.not_excluded_by_account(current_account)) if current_account
end
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View File

@ -3,6 +3,7 @@
class FollowerAccountsController < ApplicationController class FollowerAccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
include SignatureVerification include SignatureVerification
include WebAppControllerConcern
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers before_action :set_cache_headers
@ -13,12 +14,7 @@ class FollowerAccountsController < ApplicationController
def index def index
respond_to do |format| respond_to do |format|
format.html do format.html do
use_pack 'public'
expires_in 0, public: true unless user_signed_in? expires_in 0, public: true unless user_signed_in?
next if @account.hide_collections?
follows
end end
format.json do format.json do

View File

@ -3,6 +3,7 @@
class FollowingAccountsController < ApplicationController class FollowingAccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
include SignatureVerification include SignatureVerification
include WebAppControllerConcern
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers before_action :set_cache_headers
@ -13,12 +14,7 @@ class FollowingAccountsController < ApplicationController
def index def index
respond_to do |format| respond_to do |format|
format.html do format.html do
use_pack 'public'
expires_in 0, public: true unless user_signed_in? expires_in 0, public: true unless user_signed_in?
next if @account.hide_collections?
follows
end end
format.json do format.json do

View File

@ -1,47 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
class HomeController < ApplicationController class HomeController < ApplicationController
before_action :redirect_unauthenticated_to_permalinks! include WebAppControllerConcern
before_action :set_pack
before_action :set_referrer_policy_header
before_action :set_instance_presenter before_action :set_instance_presenter
def index def index
@body_classes = 'app-body' expires_in 0, public: true unless user_signed_in?
end end
private private
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
redirect_path = PermalinkRedirector.new(request.path).redirect_path
redirect_path ||= default_redirect_path
redirect_to(redirect_path) if redirect_path.present?
end
def set_pack
use_pack 'home'
end
def default_redirect_path
if whitelist_mode?
new_user_session_path
elsif request.path.start_with?('/web')
nil
elsif single_user_mode?
short_account_path(Account.local.without_suspended.where('id > 0').first)
else
about_path
end
end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin'
end
def set_instance_presenter def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end

View File

@ -1,28 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class PrivacyController < ApplicationController class PrivacyController < ApplicationController
layout 'public' include WebAppControllerConcern
before_action :set_pack
before_action :set_instance_presenter
before_action :set_expires_in
skip_before_action :require_functional! skip_before_action :require_functional!
def show; end before_action :set_instance_presenter
def show
expires_in 0, public: true if current_account.nil?
end
private private
def set_pack
use_pack 'public'
end
def set_instance_presenter def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end
def set_expires_in
expires_in 0, public: true
end
end end

View File

@ -1,31 +0,0 @@
# frozen_string_literal: true
class PublicTimelinesController < ApplicationController
before_action :set_pack
layout 'public'
before_action :authenticate_user!, if: :whitelist_mode?
before_action :require_enabled!
before_action :set_body_classes
before_action :set_instance_presenter
def show; end
private
def require_enabled!
not_found unless Setting.timeline_preview
end
def set_body_classes
@body_classes = 'with-modals'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_pack
use_pack 'about'
end
end

View File

@ -1,46 +0,0 @@
# frozen_string_literal: true
class RemoteFollowController < ApplicationController
include AccountOwnedConcern
layout 'modal'
before_action :set_pack
before_action :set_body_classes
skip_before_action :require_functional!
def new
@remote_follow = RemoteFollow.new(session_params)
end
def create
@remote_follow = RemoteFollow.new(resource_params)
if @remote_follow.valid?
session[:remote_follow] = @remote_follow.acct
redirect_to @remote_follow.subscribe_address_for(@account)
else
render :new
end
end
private
def resource_params
params.require(:remote_follow).permit(:acct)
end
def session_params
{ acct: session[:remote_follow] || current_account&.username }
end
def set_pack
use_pack 'modal'
end
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
end

View File

@ -1,60 +0,0 @@
# frozen_string_literal: true
class RemoteInteractionController < ApplicationController
include Authorization
layout 'modal'
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_interaction_type
before_action :set_status
before_action :set_body_classes
before_action :set_pack
skip_before_action :require_functional!, unless: :whitelist_mode?
def new
@remote_follow = RemoteFollow.new(session_params)
end
def create
@remote_follow = RemoteFollow.new(resource_params)
if @remote_follow.valid?
session[:remote_follow] = @remote_follow.acct
redirect_to @remote_follow.interact_address_for(@status)
else
render :new
end
end
private
def resource_params
params.require(:remote_follow).permit(:acct)
end
def session_params
{ acct: session[:remote_follow] || current_account&.username }
end
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
def set_pack
use_pack 'modal'
end
def set_interaction_type
@interaction_type = %w(reply reblog favourite).include?(params[:type]) ? params[:type] : 'reply'
end
end

View File

@ -4,7 +4,6 @@ class Settings::DeletesController < Settings::BaseController
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_not_suspended! before_action :require_not_suspended!
before_action :check_enabled_deletion
def show def show
@confirmation = Form::DeleteConfirmation.new @confirmation = Form::DeleteConfirmation.new
@ -21,10 +20,6 @@ class Settings::DeletesController < Settings::BaseController
private private
def check_enabled_deletion
redirect_to root_path unless Setting.open_deletion
end
def resource_params def resource_params
params.require(:form_delete_confirmation).permit(:password, :username) params.require(:form_delete_confirmation).permit(:password, :username)
end end

View File

@ -10,9 +10,9 @@ class Settings::FeaturedTagsController < Settings::BaseController
end end
def create def create
@featured_tag = current_account.featured_tags.new(featured_tag_params) @featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name], force: false)
if @featured_tag.save if @featured_tag.valid?
redirect_to settings_featured_tags_path redirect_to settings_featured_tags_path
else else
set_featured_tags set_featured_tags
@ -23,7 +23,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
end end
def destroy def destroy
@featured_tag.destroy! RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id)
redirect_to settings_featured_tags_path redirect_to settings_featured_tags_path
end end

View File

@ -1,21 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusesController < ApplicationController class StatusesController < ApplicationController
include WebAppControllerConcern
include StatusControllerConcern include StatusControllerConcern
include SignatureAuthentication include SignatureAuthentication
include Authorization include Authorization
include AccountOwnedConcern include AccountOwnedConcern
layout 'public'
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status before_action :set_status
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_link_headers before_action :set_link_headers
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: :show
before_action :set_referrer_policy_header, only: :show
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes before_action :set_body_classes, only: :embed
skip_around_action :set_locale, if: -> { request.format == :json } skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode? skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
@ -27,11 +25,7 @@ class StatusesController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
use_pack 'public'
expires_in 10.seconds, public: true if current_account.nil? expires_in 10.seconds, public: true if current_account.nil?
set_ancestors
set_descendants
end end
format.json do format.json do
@ -80,8 +74,4 @@ class StatusesController < ApplicationController
def redirect_to_original def redirect_to_original
redirect_to ActivityPub::TagManager.instance.url_for(@status.reblog) if @status.reblog? redirect_to ActivityPub::TagManager.instance.url_for(@status.reblog) if @status.reblog?
end end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin' unless @status.distributable?
end
end end

View File

@ -2,18 +2,16 @@
class TagsController < ApplicationController class TagsController < ApplicationController
include SignatureVerification include SignatureVerification
include WebAppControllerConcern
PAGE_SIZE = 20 PAGE_SIZE = 20
PAGE_SIZE_MAX = 200 PAGE_SIZE_MAX = 200
layout 'public'
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :authenticate_user!, if: :whitelist_mode? before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_local before_action :set_local
before_action :set_tag before_action :set_tag
before_action :set_statuses before_action :set_statuses
before_action :set_body_classes
before_action :set_instance_presenter before_action :set_instance_presenter
skip_before_action :require_functional!, unless: :whitelist_mode? skip_before_action :require_functional!, unless: :whitelist_mode?
@ -21,8 +19,7 @@ class TagsController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
use_pack 'about' expires_in 0, public: true unless user_signed_in?
expires_in 0, public: true
end end
format.rss do format.rss do
@ -55,10 +52,6 @@ class TagsController < ApplicationController
end end
end end
def set_body_classes
@body_classes = 'with-modals'
end
def set_instance_presenter def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end

View File

@ -20,54 +20,10 @@ module AccountsHelper
end end
def account_action_button(account) def account_action_button(account)
if user_signed_in? return if account.memorial? || account.moved?
if account.id == current_user.account_id
link_to settings_profile_url, class: 'button logo-button' do
safe_join([logo_as_symbol, t('settings.edit_profile')])
end
elsif current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
safe_join([logo_as_symbol, t('accounts.unfollow')])
end
elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end
elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end
end
def minimal_account_action_button(account) link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
if user_signed_in? safe_join([logo_as_symbol, t('accounts.follow')])
return if account.id == current_user.account_id
if current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
fa_icon('user-times fw')
end
elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
end
def account_badge(account)
if account.bot?
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
elsif account.group?
content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
elsif account.user_role&.highlighted?
content_tag(:div, content_tag(:div, account.user_role.name, class: "account-role user-role-#{account.user_role.id}"), class: 'roles')
end end
end end

View File

@ -1,14 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Admin::SettingsHelper module Admin::SettingsHelper
def site_upload_delete_hint(hint, var)
upload = SiteUpload.find_by(var: var.to_s)
return hint unless upload
link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete }
safe_join([hint, link], '<br/>'.html_safe)
end
def captcha_available? def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
end end

View File

@ -87,10 +87,6 @@ module ApplicationHelper
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
end end
def open_deletion?
Setting.open_deletion
end
def locale_direction def locale_direction
if RTL_LOCALES.include?(I18n.locale) if RTL_LOCALES.include?(I18n.locale)
'rtl' 'rtl'
@ -199,10 +195,7 @@ module ApplicationHelper
def render_initial_state def render_initial_state
state_params = { state_params = {
settings: { settings: {},
known_fediverse: Setting.show_known_fediverse_at_about_page,
},
text: [params[:title], params[:text], params[:url]].compact.join(' '), text: [params[:title], params[:text], params[:url]].compact.join(' '),
} }
@ -219,6 +212,10 @@ module ApplicationHelper
state_params[:admin] = Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) state_params[:admin] = Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, ''))
end end
if single_user_mode?
state_params[:owner] = Account.local.without_suspended.where('id > 0').first
end
json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json
# rubocop:disable Rails/OutputSafety # rubocop:disable Rails/OutputSafety
content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json')

View File

@ -23,7 +23,7 @@ module HomeHelper
else else
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') do content_tag(:div, class: 'account__avatar-wrapper') do
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar') image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
end + end +
content_tag(:span, class: 'display-name') do content_tag(:span, class: 'display-name') do
content_tag(:bdi) do content_tag(:bdi) do

View File

@ -6,28 +6,6 @@ import ready from '../mastodon/ready';
const { delegate } = require('@rails/ujs'); const { delegate } = require('@rails/ujs');
const { length } = require('stringz'); const { length } = require('stringz');
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
if (button !== 0) {
return true;
}
window.location.href = target.href;
return false;
});
delegate(document, '.modal-button', 'click', e => {
e.preventDefault();
let href;
if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
});
const getProfileAvatarAnimationHandler = (swapTo) => { const getProfileAvatarAnimationHandler = (swapTo) => {
//animate avatar gifs on the profile page when moused over //animate avatar gifs on the profile page when moused over
return ({ target }) => { return ({ target }) => {

View File

@ -1,6 +1,5 @@
# (REQUIRED) The location of the pack files inside `pack_directory`. # (REQUIRED) The location of the pack files inside `pack_directory`.
pack: pack:
about: about.js
admin: admin:
- admin.js - admin.js
- public.js - public.js

View File

@ -1,18 +1,20 @@
import api from '../api'; import axios from 'axios';
import { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { tagHistory } from '../settings';
import { useEmoji } from './emojis';
import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { openModal } from './modal';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import api from 'mastodon/api';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import resizeImage from 'mastodon/utils/resize_image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
import { updateTimeline } from './timelines';
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags; /** @type {AbortController | undefined} */
let fetchComposeSuggestionsAccountsController;
/** @type {AbortController | undefined} */
let fetchComposeSuggestionsTagsController;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
@ -77,10 +79,8 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
}); });
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState, routerHistory) => {
if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { if (!getState().getIn(['compose', 'mounted'])) {
routerHistory.push('/publish'); routerHistory.push('/publish');
} }
}; };
@ -435,8 +435,8 @@ export function undoUploadCompose(media_id) {
}; };
export function clearComposeSuggestions() { export function clearComposeSuggestions() {
if (cancelFetchComposeSuggestionsAccounts) { if (fetchComposeSuggestionsAccountsController) {
cancelFetchComposeSuggestionsAccounts(); fetchComposeSuggestionsAccountsController.abort();
} }
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
@ -444,14 +444,14 @@ export function clearComposeSuggestions() {
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
if (cancelFetchComposeSuggestionsAccounts) { if (fetchComposeSuggestionsAccountsController) {
cancelFetchComposeSuggestionsAccounts(); fetchComposeSuggestionsAccountsController.abort();
} }
fetchComposeSuggestionsAccountsController = new AbortController();
api(getState).get('/api/v1/accounts/search', { api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => { signal: fetchComposeSuggestionsAccountsController.signal,
cancelFetchComposeSuggestionsAccounts = cancel;
}),
params: { params: {
q: token.slice(1), q: token.slice(1),
@ -462,9 +462,11 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(readyComposeSuggestionsAccounts(token, response.data)); dispatch(readyComposeSuggestionsAccounts(token, response.data));
}).catch(error => { }).catch(error => {
if (!isCancel(error)) { if (!axios.isCancel(error)) {
dispatch(showAlertForError(error)); dispatch(showAlertForError(error));
} }
}).finally(() => {
fetchComposeSuggestionsAccountsController = undefined;
}); });
}, 200, { leading: true, trailing: true }); }, 200, { leading: true, trailing: true });
@ -474,16 +476,16 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
}; };
const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
if (cancelFetchComposeSuggestionsTags) { if (fetchComposeSuggestionsTagsController) {
cancelFetchComposeSuggestionsTags(); fetchComposeSuggestionsTagsController.abort();
} }
dispatch(updateSuggestionTags(token)); dispatch(updateSuggestionTags(token));
fetchComposeSuggestionsTagsController = new AbortController();
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => { signal: fetchComposeSuggestionsTagsController.signal,
cancelFetchComposeSuggestionsTags = cancel;
}),
params: { params: {
type: 'hashtags', type: 'hashtags',
@ -495,9 +497,11 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
}).then(({ data }) => { }).then(({ data }) => {
dispatch(readyComposeSuggestionsTags(token, data.hashtags)); dispatch(readyComposeSuggestionsTags(token, data.hashtags));
}).catch(error => { }).catch(error => {
if (!isCancel(error)) { if (!axios.isCancel(error)) {
dispatch(showAlertForError(error)); dispatch(showAlertForError(error));
} }
}).finally(() => {
fetchComposeSuggestionsTagsController = undefined;
}); });
}, 200, { leading: true, trailing: true }); }, 200, { leading: true, trailing: true });

View File

@ -0,0 +1,34 @@
import api from '../api';
export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL';
export const fetchFeaturedTags = (id) => (dispatch, getState) => {
if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
return;
}
dispatch(fetchFeaturedTagsRequest(id));
api(getState).get(`/api/v1/accounts/${id}/featured_tags`)
.then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
.catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
};
export const fetchFeaturedTagsRequest = (id) => ({
type: FEATURED_TAGS_FETCH_REQUEST,
id,
});
export const fetchFeaturedTagsSuccess = (id, tags) => ({
type: FEATURED_TAGS_FETCH_SUCCESS,
id,
tags,
});
export const fetchFeaturedTagsFail = (id, error) => ({
type: FEATURED_TAGS_FETCH_FAIL,
id,
error,
});

View File

@ -5,6 +5,14 @@ export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS'; export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL'; export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL';
export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST';
export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS';
export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => { export const fetchServer = () => (dispatch, getState) => {
dispatch(fetchServerRequest()); dispatch(fetchServerRequest());
@ -28,3 +36,56 @@ const fetchServerFail = error => ({
type: SERVER_FETCH_FAIL, type: SERVER_FETCH_FAIL,
error, error,
}); });
export const fetchExtendedDescription = () => (dispatch, getState) => {
dispatch(fetchExtendedDescriptionRequest());
api(getState)
.get('/api/v1/instance/extended_description')
.then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data)))
.catch(err => dispatch(fetchExtendedDescriptionFail(err)));
};
const fetchExtendedDescriptionRequest = () => ({
type: EXTENDED_DESCRIPTION_REQUEST,
});
const fetchExtendedDescriptionSuccess = description => ({
type: EXTENDED_DESCRIPTION_SUCCESS,
description,
});
const fetchExtendedDescriptionFail = error => ({
type: EXTENDED_DESCRIPTION_FAIL,
error,
});
export const fetchDomainBlocks = () => (dispatch, getState) => {
dispatch(fetchDomainBlocksRequest());
api(getState)
.get('/api/v1/instance/domain_blocks')
.then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data)))
.catch(err => {
if (err.response.status === 404) {
dispatch(fetchDomainBlocksSuccess(false, []));
} else {
dispatch(fetchDomainBlocksFail(err));
}
});
};
const fetchDomainBlocksRequest = () => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST,
});
const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS,
isAvailable,
blocks,
});
const fetchDomainBlocksFail = error => ({
type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL,
error,
});

View File

@ -143,8 +143,8 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done); export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, tagged, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {

View File

@ -1,20 +1,31 @@
// @ts-check
import axios from 'axios'; import axios from 'axios';
import LinkHeader from 'http-link-header'; import LinkHeader from 'http-link-header';
import ready from './ready'; import ready from './ready';
/**
* @param {import('axios').AxiosResponse} response
* @returns {LinkHeader}
*/
export const getLinks = response => { export const getLinks = response => {
const value = response.headers.link; const value = response.headers.link;
if (!value) { if (!value) {
return { refs: [] }; return new LinkHeader();
} }
return LinkHeader.parse(value); return LinkHeader.parse(value);
}; };
/** @type {import('axios').RawAxiosRequestHeaders} */
const csrfHeader = {}; const csrfHeader = {};
/**
* @returns {void}
*/
const setCSRFHeader = () => { const setCSRFHeader = () => {
/** @type {HTMLMetaElement | null} */
const csrfToken = document.querySelector('meta[name=csrf-token]'); const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) { if (csrfToken) {
@ -24,6 +35,10 @@ const setCSRFHeader = () => {
ready(setCSRFHeader); ready(setCSRFHeader);
/**
* @param {() => import('immutable').Map} getState
* @returns {import('axios').RawAxiosRequestHeaders}
*/
const authorizationHeaderFromState = getState => { const authorizationHeaderFromState = getState => {
const accessToken = getState && getState().getIn(['meta', 'access_token'], ''); const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
@ -36,17 +51,25 @@ const authorizationHeaderFromState = getState => {
}; };
}; };
export default getState => axios.create({ /**
headers: { * @param {() => import('immutable').Map} getState
...csrfHeader, * @returns {import('axios').AxiosInstance}
...authorizationHeaderFromState(getState), */
}, export default function api(getState) {
return axios.create({
headers: {
...csrfHeader,
...authorizationHeaderFromState(getState),
},
transformResponse: [function (data) { transformResponse: [
try { function (data) {
return JSON.parse(data); try {
} catch(Exception) { return JSON.parse(data);
return data; } catch {
} return data;
}], }
}); },
],
});
}

View File

@ -7,13 +7,16 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
onMouseLeave={[Function]} onMouseLeave={[Function]}
style={ style={
{ {
"backgroundImage": "url(/animated/alice.gif)",
"backgroundSize": "100px 100px",
"height": "100px", "height": "100px",
"width": "100px", "width": "100px",
} }
} }
/> >
<img
alt="alice"
src="/animated/alice.gif"
/>
</div>
`; `;
exports[`<Avatar /> Still renders a still avatar 1`] = ` exports[`<Avatar /> Still renders a still avatar 1`] = `
@ -23,11 +26,14 @@ exports[`<Avatar /> Still renders a still avatar 1`] = `
onMouseLeave={[Function]} onMouseLeave={[Function]}
style={ style={
{ {
"backgroundImage": "url(/static/alice.jpg)",
"backgroundSize": "100px 100px",
"height": "100px", "height": "100px",
"width": "100px", "width": "100px",
} }
} }
/> >
<img
alt="alice"
src="/static/alice.jpg"
/>
</div>
`; `;

View File

@ -3,22 +3,52 @@
exports[`<AvatarOverlay renders a overlay avatar 1`] = ` exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div <div
className="account__avatar-overlay" className="account__avatar-overlay"
style={
{
"height": 46,
"width": 46,
}
}
> >
<div <div
className="account__avatar-overlay-base" className="account__avatar-overlay-base"
style={ >
{ <div
"backgroundImage": "url(/static/alice.jpg)", className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "36px",
"width": "36px",
}
} }
} >
/> <img
alt="alice"
src="/static/alice.jpg"
/>
</div>
</div>
<div <div
className="account__avatar-overlay-overlay" className="account__avatar-overlay-overlay"
style={ >
{ <div
"backgroundImage": "url(/static/eve.jpg)", className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "24px",
"width": "24px",
}
} }
} >
/> <img
alt="eve@blackhat.lair"
src="/static/eve.jpg"
/>
</div>
</div>
</div> </div>
`; `;

View File

@ -136,7 +136,7 @@ class Account extends ImmutablePureComponent {
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={46} /></div>
{mute_expires_at} {mute_expires_at}
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

View File

@ -42,28 +42,20 @@ export default class Avatar extends React.PureComponent {
...this.props.style, ...this.props.style,
width: `${size}px`, width: `${size}px`,
height: `${size}px`, height: `${size}px`,
backgroundSize: `${size}px ${size}px`,
}; };
if (account) { let src;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
if (hovering || animate) { if (hovering || animate) {
style.backgroundImage = `url(${src})`; src = account?.get('avatar');
} else { } else {
style.backgroundImage = `url(${staticSrc})`; src = account?.get('avatar_static');
}
} }
return ( return (
<div <div className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style}>
className={classNames('account__avatar', { 'account__avatar-inline': inline })} <img src={src} alt={account?.get('acct')} />
onMouseEnter={this.handleMouseEnter} </div>
onMouseLeave={this.handleMouseLeave}
style={style}
/>
); );
} }

View File

@ -2,6 +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 { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
export default class AvatarComposite extends React.PureComponent { export default class AvatarComposite extends React.PureComponent {
@ -74,12 +75,12 @@ export default class AvatarComposite extends React.PureComponent {
bottom: bottom, bottom: bottom,
width: `${width}%`, width: `${width}%`,
height: `${height}%`, height: `${height}%`,
backgroundSize: 'cover',
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
}; };
return ( return (
<div key={account.get('id')} style={style} /> <div key={account.get('id')} style={style}>
<Avatar account={account} animate={animate} />
</div>
); );
} }

View File

@ -2,6 +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 { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
export default class AvatarOverlay extends React.PureComponent { export default class AvatarOverlay extends React.PureComponent {
@ -9,27 +10,40 @@ export default class AvatarOverlay extends React.PureComponent {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired, friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool, animate: PropTypes.bool,
size: PropTypes.number,
baseSize: PropTypes.number,
overlaySize: PropTypes.number,
}; };
static defaultProps = { static defaultProps = {
animate: autoPlayGif, animate: autoPlayGif,
size: 46,
baseSize: 36,
overlaySize: 24,
}; };
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
}
render() { render() {
const { account, friend, animate } = this.props; const { account, friend, animate, size, baseSize, overlaySize } = this.props;
const { hovering } = this.state;
const baseStyle = {
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
};
return ( return (
<div className='account__avatar-overlay'> <div className='account__avatar-overlay' style={{ width: size, height: size }}>
<div className='account__avatar-overlay-base' style={baseStyle} /> <div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div>
<div className='account__avatar-overlay-overlay' style={overlayStyle} /> <div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div>
</div> </div>
); );
} }

View File

@ -0,0 +1,51 @@
import React from 'react';
import IconButton from './icon_button';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
export default @injectIntl
class DismissableBanner extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
}
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { version, source_url } from 'mastodon/initial_state'; import { version, source_url } from 'mastodon/initial_state';
import StackTrace from 'stacktrace-js'; import StackTrace from 'stacktrace-js';
import { Helmet } from 'react-helmet';
export default class ErrorBoundary extends React.PureComponent { export default class ErrorBoundary extends React.PureComponent {
@ -84,6 +85,7 @@ export default class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /> <FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' />
)} )}
</p> </p>
<p> <p>
{ likelyBrowserAddonIssue ? ( { likelyBrowserAddonIssue ? (
<FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> <FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
@ -91,8 +93,13 @@ export default class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> <FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
)} )}
</p> </p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div> </div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div> </div>
); );
} }

View File

@ -65,23 +65,35 @@ ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired, hashtag: ImmutablePropTypes.map.isRequired,
}; };
const Hashtag = ({ name, href, to, people, history, className }) => ( const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => (
<div className={classNames('trends__item', className)}> <div className={classNames('trends__item', className)}>
<div className='trends__item__name'> <div className='trends__item__name'>
<Permalink href={href} to={to}> <Permalink href={href} to={to}>
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />} {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
</Permalink> </Permalink>
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />} {description ? (
<span>{description}</span>
) : (
typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />
)}
</div> </div>
<div className='trends__item__sparkline'> {typeof uses !== 'undefined' && (
<SilentErrorBoundary> <div className='trends__item__current'>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}> <ShortNumber value={uses} />
<SparklinesCurve style={{ fill: 'none' }} /> </div>
</Sparklines> )}
</SilentErrorBoundary>
</div> {withGraph && (
<div className='trends__item__sparkline'>
<SilentErrorBoundary>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</SilentErrorBoundary>
</div>
)}
</div> </div>
); );
@ -90,9 +102,15 @@ Hashtag.propTypes = {
href: PropTypes.string, href: PropTypes.string,
to: PropTypes.string, to: PropTypes.string,
people: PropTypes.number, people: PropTypes.number,
description: PropTypes.node,
uses: PropTypes.number, uses: PropTypes.number,
history: PropTypes.arrayOf(PropTypes.number), history: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string, className: PropTypes.string,
withGraph: PropTypes.bool,
};
Hashtag.defaultProps = {
withGraph: true,
}; };
export default Hashtag; export default Hashtag;

View File

@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import Blurhash from './blurhash';
import classNames from 'classnames';
export default class Image extends React.PureComponent {
static propTypes = {
src: PropTypes.string,
srcSet: PropTypes.string,
blurhash: PropTypes.string,
className: PropTypes.string,
};
state = {
loaded: false,
};
handleLoad = () => this.setState({ loaded: true });
render () {
const { src, srcSet, blurhash, className } = this.props;
const { loaded } = this.state;
return (
<div className={classNames('image', { loaded }, className)} role='presentation'>
{blurhash && <Blurhash hash={blurhash} className='image__preview' />}
<img src={src} srcSet={srcSet} alt='' onLoad={this.handleLoad} />
</div>
);
}
}

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
const Logo = () => ( const Logo = () => (
<svg viewBox='0 0 261 66' className='logo'> <svg viewBox='0 0 261 66' className='logo' role='img'>
<title>Mastodon</title>
<use xlinkHref='#logo-symbol-wordmark' /> <use xlinkHref='#logo-symbol-wordmark' />
</svg> </svg>
); );

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import illustration from 'mastodon/../images/elephant_ui_disappointed.svg'; import illustration from 'mastodon/../images/elephant_ui_disappointed.svg';
import classNames from 'classnames'; import classNames from 'classnames';
import { Helmet } from 'react-helmet';
const MissingIndicator = ({ fullPage }) => ( const MissingIndicator = ({ fullPage }) => (
<div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}>
@ -14,6 +15,10 @@ const MissingIndicator = ({ fullPage }) => (
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
</div> </div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div> </div>
); );

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Switch, Route, withRouter } from 'react-router-dom';
import { showTrends } from 'mastodon/initial_state';
import Trends from 'mastodon/features/getting_started/containers/trends_container';
import AccountNavigation from 'mastodon/features/account/navigation';
const DefaultNavigation = () => (
<>
{showTrends && (
<>
<div className='flex-spacer' />
<Trends />
</>
)}
</>
);
export default @withRouter
class NavigationPortal extends React.PureComponent {
render () {
return (
<Switch>
<Route path='/@:acct/(tagged/:tagged?)?' component={AccountNavigation} />
<Route component={DefaultNavigation} />
</Switch>
);
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
const NotSignedInIndicator = () => (
<div className='scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
</div>
</div>
);
export default NotSignedInIndicator;

View File

@ -1,19 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { domain } from 'mastodon/initial_state'; import React from 'react';
import { fetchServer } from 'mastodon/actions/server'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Account from 'mastodon/containers/account_container'; import { fetchServer } from 'mastodon/actions/server';
import ShortNumber from 'mastodon/components/short_number'; import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton'; import Skeleton from 'mastodon/components/skeleton';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';
import Image from 'mastodon/components/image';
import { Link } from 'react-router-dom';
const messages = defineMessages({ const messages = defineMessages({
aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' }, aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
server: state.get('server'), server: state.getIn(['server', 'server']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -41,7 +43,7 @@ class ServerBanner extends React.PureComponent {
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} /> <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
</div> </div>
<img src={server.get('thumbnail')} alt={server.get('title')} className='server-banner__hero' /> <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
<div className='server-banner__description'> <div className='server-banner__description'>
{isLoading ? ( {isLoading ? (
@ -83,7 +85,7 @@ class ServerBanner extends React.PureComponent {
<hr className='spacer' /> <hr className='spacer' />
<a className='button button--block button-secondary' href='/about/more' target='_blank'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></a> <Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link>
</div> </div>
); );
} }

View File

@ -4,8 +4,8 @@ import PropTypes from 'prop-types';
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>; const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
Skeleton.propTypes = { Skeleton.propTypes = {
width: PropTypes.number, width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.number, height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
}; };
export default Skeleton; export default Skeleton;

View File

@ -86,6 +86,7 @@ class Status extends ImmutablePureComponent {
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func, onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool, unread: PropTypes.bool,
@ -385,6 +386,15 @@ class Status extends ImmutablePureComponent {
account = status.get('account'); account = status.get('account');
status = status.get('reblog'); status = status.get('reblog');
} else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
} }
if (pictureInPicture.get('inUse')) { if (pictureInPicture.get('inUse')) {
@ -480,7 +490,7 @@ class Status extends ImmutablePureComponent {
} }
if (account === undefined || account === null) { if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={48} />; statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else { } else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
} }
@ -500,8 +510,6 @@ class Status extends ImmutablePureComponent {
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
@ -521,7 +529,6 @@ class Status extends ImmutablePureComponent {
status={status} status={status}
onClick={this.handleClick} onClick={this.handleClick}
expanded={!status.get('hidden')} expanded={!status.get('hidden')}
showThread={showThread}
onExpandedToggle={this.handleExpandedToggle} onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
collapsable collapsable

View File

@ -82,6 +82,7 @@ class StatusActionBar extends ImmutablePureComponent {
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onFilter: PropTypes.func, onFilter: PropTypes.func,
onAddFilter: PropTypes.func, onAddFilter: PropTypes.func,
onInteractionModal: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
withCounters: PropTypes.bool, withCounters: PropTypes.bool,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
@ -97,10 +98,12 @@ class StatusActionBar extends ImmutablePureComponent {
] ]
handleReplyClick = () => { handleReplyClick = () => {
if (me) { const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReply(this.props.status, this.context.router.history); this.props.onReply(this.props.status, this.context.router.history);
} else { } else {
this._openInteractionDialog('reply'); this.props.onInteractionModal('reply', this.props.status);
} }
} }
@ -114,25 +117,25 @@ class StatusActionBar extends ImmutablePureComponent {
} }
handleFavouriteClick = () => { handleFavouriteClick = () => {
if (me) { const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onFavourite(this.props.status); this.props.onFavourite(this.props.status);
} else { } else {
this._openInteractionDialog('favourite'); this.props.onInteractionModal('favourite', this.props.status);
} }
} }
handleReblogClick = e => { handleReblogClick = e => {
if (me) { const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReblog(this.props.status, e); this.props.onReblog(this.props.status, e);
} else { } else {
this._openInteractionDialog('reblog'); this.props.onInteractionModal('reblog', this.props.status);
} }
} }
_openInteractionDialog = type => {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
handleBookmarkClick = () => { handleBookmarkClick = () => {
this.props.onBookmark(this.props.status); this.props.onBookmark(this.props.status);
} }
@ -243,8 +246,9 @@ class StatusActionBar extends ImmutablePureComponent {
render () { render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn } = this.context.identity;
const anonymousAccess = !me; const anonymousAccess = !signedIn;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
@ -276,7 +280,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
if (writtenByMe) { if (writtenByMe) {
// menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {
@ -319,7 +323,7 @@ class StatusActionBar extends ImmutablePureComponent {
if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
} }
} }
@ -347,24 +351,25 @@ class StatusActionBar extends ImmutablePureComponent {
} }
const shareButton = ('share' in navigator) && publicStatus && ( const shareButton = ('share' in navigator) && publicStatus && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> <IconButton className='status__action-bar__button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
); );
const filterButton = this.props.onFilter && ( const filterButton = this.props.onFilter && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} /> <IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
); );
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> <IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> <IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} /> <IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{shareButton} {shareButton}
{filterButton} {filterButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar__dropdown'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
disabled={anonymousAccess} disabled={anonymousAccess}

View File

@ -6,10 +6,47 @@ import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container'; import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
class TranslateButton extends React.PureComponent {
static propTypes = {
translation: ImmutablePropTypes.map,
onClick: PropTypes.func,
};
render () {
const { translation, onClick } = this.props;
if (translation) {
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
const languageName = language ? language[2] : translation.get('detected_source_language');
const provider = translation.get('provider');
return (
<div className='translate-button'>
<div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div>
<button className='link-button' onClick={onClick}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</div>
);
}
return (
<button className='status__content__read-more-button' onClick={onClick}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);
}
}
export default @injectIntl export default @injectIntl
class StatusContent extends React.PureComponent { class StatusContent extends React.PureComponent {
@ -21,7 +58,6 @@ class StatusContent extends React.PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.bool, expanded: PropTypes.bool,
showThread: PropTypes.bool,
onExpandedToggle: PropTypes.func, onExpandedToggle: PropTypes.func,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
@ -61,9 +97,6 @@ class StatusContent extends React.PureComponent {
link.setAttribute('title', link.href); link.setAttribute('title', link.href);
link.classList.add('unhandled-link'); link.classList.add('unhandled-link');
} }
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
} }
if (this.props.status.get('collapsed', null) === null) { if (this.props.status.get('collapsed', null) === null) {
@ -180,10 +213,7 @@ class StatusContent extends React.PureComponent {
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language');
const renderTranslate = this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && intl.locale !== status.get('language');
const language = preloadedLanguages.find(lang => lang[0] === status.get('language'));
const languageName = language ? language[2] : status.get('language');
const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') }; const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') };
@ -194,22 +224,18 @@ class StatusContent extends React.PureComponent {
'status__content--collapsed': renderReadMore, 'status__content--collapsed': renderReadMore,
}); });
const showThreadButton = ( const readMoreButton = renderReadMore && (
<button className='status__content__read-more-button' onClick={this.props.onClick}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
</button>
);
const readMoreButton = (
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'> <button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth /> <FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
</button> </button>
); );
const translateButton = ( const translateButton = renderTranslate && (
<button className='status__content__read-more-button' onClick={this.handleTranslate}> <TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} />
{status.get('translation') ? <span><FormattedMessage id='status.translated_from' defaultMessage='Translated from {lang}' values={{ lang: languageName }} /> · <FormattedMessage id='status.show_original' defaultMessage='Show original' /></span> : <FormattedMessage id='status.translate' defaultMessage='Translate' />} );
</button>
const poll = !!status.get('poll') && (
<PollContainer pollId={status.get('poll')} />
); );
if (status.get('spoiler_text').length > 0) { if (status.get('spoiler_text').length > 0) {
@ -239,35 +265,30 @@ class StatusContent extends React.PureComponent {
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} /> <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} />
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {!hidden && poll}
{!hidden && renderTranslate && translateButton} {!hidden && translateButton}
{renderViewThread && showThreadButton}
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
const output = [ return (
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <>
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {poll}
{renderTranslate && translateButton} {translateButton}
{renderViewThread && showThreadButton} </div>
</div>,
];
if (renderReadMore) { {readMoreButton}
output.push(readMoreButton); </>
} );
return output;
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {poll}
{renderTranslate && translateButton} {translateButton}
{renderViewThread && showThreadButton}
</div> </div>
); );
} }

View File

@ -1,21 +1,24 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from '../store/configureStore'; import React from 'react';
import { Helmet } from 'react-helmet';
import { IntlProvider, addLocaleData } from 'react-intl';
import { Provider as ReduxProvider } from 'react-redux';
import { BrowserRouter, Route } from 'react-router-dom'; import { BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll-4'; import { ScrollContext } from 'react-router-scroll-4';
import UI from '../features/ui'; import configureStore from 'mastodon/store/configureStore';
import { fetchCustomEmojis } from '../actions/custom_emojis'; import UI from 'mastodon/features/ui';
import { hydrateStore } from '../actions/store'; import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { connectUserStream } from '../actions/streaming'; import { hydrateStore } from 'mastodon/actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { connectUserStream } from 'mastodon/actions/streaming';
import { getLocale } from '../locales'; import ErrorBoundary from 'mastodon/components/error_boundary';
import initialState from '../initial_state'; import initialState, { title as siteTitle } from 'mastodon/initial_state';
import ErrorBoundary from '../components/error_boundary'; import { getLocale } from 'mastodon/locales';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
export const store = configureStore(); export const store = configureStore();
const hydrateAction = hydrateStore(initialState); const hydrateAction = hydrateStore(initialState);
@ -73,15 +76,17 @@ export default class Mastodon extends React.PureComponent {
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<Provider store={store}> <ReduxProvider store={store}>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter basename='/web'> <BrowserRouter>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} /> <Route path='/' component={UI} />
</ScrollContext> </ScrollContext>
</BrowserRouter> </BrowserRouter>
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
</ErrorBoundary> </ErrorBoundary>
</Provider> </ReduxProvider>
</IntlProvider> </IntlProvider>
); );
} }

View File

@ -237,6 +237,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
}, },
onInteractionModal (type, status) {
dispatch(openModal('INTERACTION', {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

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

View File

@ -0,0 +1,222 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Column from 'mastodon/components/column';
import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { Helmet } from 'react-helmet';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import Account from 'mastodon/containers/account_container';
import Skeleton from 'mastodon/components/skeleton';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import Image from 'mastodon/components/image';
const messages = defineMessages({
title: { id: 'column.about', defaultMessage: 'About' },
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
});
const severityMessages = {
silence: {
title: messages.silenced,
explanation: messages.silencedExplanation,
},
suspend: {
title: messages.suspended,
explanation: messages.suspendedExplanation,
},
};
const mapStateToProps = state => ({
server: state.getIn(['server', 'server']),
extendedDescription: state.getIn(['server', 'extendedDescription']),
domainBlocks: state.getIn(['server', 'domainBlocks']),
});
class Section extends React.PureComponent {
static propTypes = {
title: PropTypes.string,
children: PropTypes.node,
open: PropTypes.bool,
onOpen: PropTypes.func,
};
state = {
collapsed: !this.props.open,
};
handleClick = () => {
const { onOpen } = this.props;
const { collapsed } = this.state;
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
}
render () {
const { title, children } = this.props;
const { collapsed } = this.state;
return (
<div className={classNames('about__section', { active: !collapsed })}>
<div className='about__section__title' role='button' tabIndex='0' onClick={this.handleClick}>
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} fixedWidth /> {title}
</div>
{!collapsed && (
<div className='about__section__body'>{children}</div>
)}
</div>
);
}
}
export default @connect(mapStateToProps)
@injectIntl
class About extends React.PureComponent {
static propTypes = {
server: ImmutablePropTypes.map,
extendedDescription: ImmutablePropTypes.map,
domainBlocks: ImmutablePropTypes.contains({
isLoading: PropTypes.bool,
isAvailable: PropTypes.bool,
items: ImmutablePropTypes.list,
}),
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchServer());
dispatch(fetchExtendedDescription());
}
handleDomainBlocksOpen = () => {
const { dispatch } = this.props;
dispatch(fetchDomainBlocks());
}
render () {
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const isLoading = server.get('isLoading');
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable about'>
<div className='about__header'>
<Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
</div>
<div className='about__meta'>
<div className='about__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} />
</div>
<hr className='about__meta__divider' />
<div className='about__meta__column'>
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
</div>
</div>
<Section open title={intl.formatMessage(messages.title)}>
{extendedDescription.get('isLoading') ? (
<>
<Skeleton width='100%' />
<br />
<Skeleton width='100%' />
<br />
<Skeleton width='100%' />
<br />
<Skeleton width='70%' />
</>
) : (extendedDescription.get('content')?.length > 0 ? (
<div
className='prose'
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
/>
) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
))}
</Section>
<Section title={intl.formatMessage(messages.rules)}>
{!isLoading && (server.get('rules').isEmpty() ? (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : (
<ol className='rules-list'>
{server.get('rules').map(rule => (
<li key={rule.get('id')}>
<span className='rules-list__text'>{rule.get('text')}</span>
</li>
))}
</ol>
))}
</Section>
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
{domainBlocks.get('isLoading') ? (
<>
<Skeleton width='100%' />
<br />
<Skeleton width='70%' />
</>
) : (domainBlocks.get('isAvailable') ? (
<>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<table className='about__domain-blocks'>
<thead>
<tr>
<th><FormattedMessage id='about.domain_blocks.domain' defaultMessage='Domain' /></th>
<th><FormattedMessage id='about.domain_blocks.severity' defaultMessage='Severity' /></th>
<th><FormattedMessage id='about.domain_blocks.comment' defaultMessage='Reason' /></th>
</tr>
</thead>
<tbody>
{domainBlocks.get('items').map(block => (
<tr key={block.get('domain')}>
<td><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></td>
<td><span title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span></td>
<td>{block.get('comment')}</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
))}
</Section>
<LinkFooter />
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
}
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Hashtag from 'mastodon/components/hashtag';
const messages = defineMessages({
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
});
export default @injectIntl
class FeaturedTags extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
account: ImmutablePropTypes.map,
featuredTags: ImmutablePropTypes.list,
tagged: PropTypes.string,
intl: PropTypes.object.isRequired,
};
render () {
const { account, featuredTags, intl } = this.props;
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
return null;
}
return (
<div className='getting-started__trends'>
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
{featuredTags.take(3).map(featuredTag => (
<Hashtag
key={featuredTag.get('name')}
name={featuredTag.get('name')}
href={featuredTag.get('url')}
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
uses={featuredTag.get('statuses_count') * 1}
withGraph={false}
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
/>
))}
</div>
);
}
}

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me, title, domain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain } from 'mastodon/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
@ -20,7 +20,7 @@ import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' }, cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -96,6 +96,7 @@ class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -177,7 +178,7 @@ class Header extends ImmutablePureComponent {
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : undefined} />; actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
} }
@ -269,7 +270,9 @@ class Header extends ImmutablePureComponent {
const content = { __html: account.get('note_emojified') }; const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') }; const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields'); const fields = account.get('fields');
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); const isLocal = account.get('acct').indexOf('@') === -1;
const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
const isIndexable = !account.get('noindex');
let badge; let badge;
@ -323,25 +326,26 @@ class Header extends ImmutablePureComponent {
{!(suspended || hidden) && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
<div className='account__header__bio'> <div className='account__header__bio'>
{fields.size > 0 && (
<div className='account__header__fields'>
{fields.map((pair, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
<dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
</div>
)}
{(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />} {(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
<div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div> <div className='account__header__fields'>
<dl>
<dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
<dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
</dl>
{fields.map((pair, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
<dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
</div>
</div> </div>
<div className='account__header__extra__links'> <div className='account__header__extra__links'>
@ -371,7 +375,8 @@ class Header extends ImmutablePureComponent {
</div> </div>
<Helmet> <Helmet>
<title>{titleFromAccount(account)} - {title}</title> <title>{titleFromAccount(account)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
</Helmet> </Helmet>
</div> </div>
); );

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import FeaturedTags from '../components/featured_tags';
import { makeGetAccount } from 'mastodon/selectors';
import { List as ImmutableList } from 'immutable';
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return (state, { accountId }) => ({
account: getAccount(state, accountId),
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
});
};
export default connect(mapStateToProps)(FeaturedTags);

View File

@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
const mapStateToProps = (state, { match: { params: { acct } } }) => {
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
isLoading: false,
};
};
export default @connect(mapStateToProps)
class AccountNavigation extends React.PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
acct: PropTypes.string,
tagged: PropTypes.string,
}).isRequired,
}).isRequired,
accountId: PropTypes.string,
isLoading: PropTypes.bool,
};
render () {
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
if (isLoading) {
return null;
}
return (
<>
<div className='flex-spacer' />
<FeaturedTags accountId={accountId} tagged={tagged} />
</>
);
}
}

View File

@ -16,9 +16,10 @@ import LoadMore from 'mastodon/components/load_more';
import MissingIndicator from 'mastodon/components/missing_indicator'; import MissingIndicator from 'mastodon/components/missing_indicator';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]); const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) { if (!accountId) {
return { return {

View File

@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
onEndorseToggle: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
hideTabs: PropTypes.bool, hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -96,6 +97,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onChangeLanguages(this.props.account); this.props.onChangeLanguages(this.props.account);
} }
handleInteractionModal = () => {
this.props.onInteractionModal(this.props.account);
}
render () { render () {
const { account, hidden, hideTabs } = this.props; const { account, hidden, hideTabs } = this.props;
@ -123,6 +128,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList} onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote} onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages} onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
domain={this.props.domain} domain={this.props.domain}
hidden={hidden} hidden={hidden}
/> />

View File

@ -23,6 +23,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from '../../../initial_state'; import { unfollowModal } from '../../../initial_state';
const messages = defineMessages({ const messages = defineMessages({
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
}); });
@ -42,7 +43,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) { onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (account.getIn(['relationship', 'following'])) {
if (unfollowModal) { if (unfollowModal) {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
@ -52,11 +53,27 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} else { } else {
dispatch(unfollowAccount(account.get('id'))); dispatch(unfollowAccount(account.get('id')));
} }
} else if (account.getIn(['relationship', 'requested'])) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
}));
}
} else { } else {
dispatch(followAccount(account.get('id'))); dispatch(followAccount(account.get('id')));
} }
}, },
onInteractionModal (account) {
dispatch(openModal('INTERACTION', {
type: 'follow',
accountId: account.get('id'),
url: account.get('url'),
}));
},
onBlock (account) { onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) { if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id'))); dispatch(unblockAccount(account.get('id')));

View File

@ -18,19 +18,22 @@ import { me } from 'mastodon/initial_state';
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines'; import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
import LimitedAccountHint from './components/limited_account_hint'; import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors'; import { getAccountHidden } from 'mastodon/selectors';
import { fetchFeaturedTags } from '../../actions/featured_tags';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
const emptyList = ImmutableList(); const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => { const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
const accountId = id || state.getIn(['accounts_map', acct]); const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) { if (!accountId) {
return { return {
isLoading: true, isLoading: true,
statusIds: emptyList,
}; };
} }
const path = withReplies ? `${accountId}:with_replies` : accountId; const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
return { return {
accountId, accountId,
@ -38,7 +41,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
remoteUrl: state.getIn(['accounts', accountId, 'url']), remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]), isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false), suspended: state.getIn(['accounts', accountId, 'suspended'], false),
@ -62,6 +65,7 @@ class AccountTimeline extends ImmutablePureComponent {
params: PropTypes.shape({ params: PropTypes.shape({
acct: PropTypes.string, acct: PropTypes.string,
id: PropTypes.string, id: PropTypes.string,
tagged: PropTypes.string,
}).isRequired, }).isRequired,
accountId: PropTypes.string, accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
@ -80,15 +84,16 @@ class AccountTimeline extends ImmutablePureComponent {
}; };
_load () { _load () {
const { accountId, withReplies, dispatch } = this.props; const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
dispatch(fetchAccount(accountId)); dispatch(fetchAccount(accountId));
if (!withReplies) { if (!withReplies) {
dispatch(expandAccountFeaturedTimeline(accountId)); dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
} }
dispatch(expandAccountTimeline(accountId, { withReplies })); dispatch(fetchFeaturedTags(accountId));
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
if (accountId === me) { if (accountId === me) {
dispatch(connectTimeline(`account:${me}`)); dispatch(connectTimeline(`account:${me}`));
@ -106,12 +111,17 @@ class AccountTimeline extends ImmutablePureComponent {
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props; const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) { if (prevProps.accountId !== accountId && accountId) {
this._load(); this._load();
} else if (prevProps.params.acct !== acct) { } else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct)); dispatch(lookupAccount(acct));
} else if (prevProps.params.tagged !== tagged) {
if (!withReplies) {
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
}
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
} }
if (prevProps.accountId === me && accountId !== me) { if (prevProps.accountId === me && accountId !== me) {
@ -128,13 +138,19 @@ class AccountTimeline extends ImmutablePureComponent {
} }
handleLoadMore = maxId => { handleLoadMore = maxId => {
this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies })); this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
} }
render () { render () {
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (isLoading && statusIds.isEmpty()) {
return (
<Column>
<LoadingIndicator />
</Column>
);
} else if (!isLoading && !isAccount) {
return ( return (
<Column> <Column>
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
@ -143,14 +159,6 @@ class AccountTimeline extends ImmutablePureComponent {
); );
} }
if (!statusIds && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage; let emptyMessage;
const forceEmptyState = suspended || blockedBy || hidden; const forceEmptyState = suspended || blockedBy || hidden;
@ -174,7 +182,7 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'

View File

@ -1,15 +1,16 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
@ -95,6 +96,11 @@ class Bookmarks extends ImmutablePureComponent {
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column> </Column>
); );
} }

View File

@ -0,0 +1,75 @@
import React from 'react';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { domain } from 'mastodon/initial_state';
import { fetchServer } from 'mastodon/actions/server';
const mapStateToProps = state => ({
closed_registrations_message: state.getIn(['server', 'server', 'registrations', 'closed_registrations_message']),
});
export default @connect(mapStateToProps)
class ClosedRegistrationsModal extends ImmutablePureComponent {
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchServer());
}
render () {
let closedRegistrationsMessage;
if (this.props.closed_registrations_message) {
closedRegistrationsMessage = (
<p
className='prose'
dangerouslySetInnerHTML={{ __html: this.props.closed_registrations_message }}
/>
);
} else {
closedRegistrationsMessage = (
<p className='prose'>
<FormattedMessage
id='closed_registrations_modal.description'
defaultMessage='Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.'
values={{ domain: <strong>{domain}</strong> }}
/>
</p>
);
}
return (
<div className='modal-root__modal interaction-modal'>
<div className='interaction-modal__lead'>
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
<p>
<FormattedMessage
id='closed_registrations_modal.preamble'
defaultMessage='Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!'
/>
</p>
</div>
<div className='interaction-modal__choices'>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
{closedRegistrationsMessage}
</div>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
<p className='prose'>
<FormattedMessage
id='closed_registrations.other_server_instructions'
defaultMessage='Since Mastodon is decentralized, you can create an account on another server and still interact with this one.'
/>
</p>
<a href='https://joinmastodon.org/servers' className='button button--block'><FormattedMessage id='closed_registrations_modal.find_another_server' defaultMessage='Find another server' /></a>
</div>
</div>
</div>
);
}
};

View File

@ -10,7 +10,8 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectCommunityStream } from '../../actions/streaming'; import { connectCommunityStream } from '../../actions/streaming';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { title } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';
import DismissableBanner from 'mastodon/components/dismissable_banner';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
@ -35,6 +36,7 @@ class CommunityTimeline extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static defaultProps = { static defaultProps = {
@ -71,18 +73,30 @@ class CommunityTimeline extends React.PureComponent {
componentDidMount () { componentDidMount () {
const { dispatch, onlyMedia } = this.props; const { dispatch, onlyMedia } = this.props;
const { signedIn } = this.context.identity;
dispatch(expandCommunityTimeline({ onlyMedia })); dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { signedIn } = this.context.identity;
if (prevProps.onlyMedia !== this.props.onlyMedia) { if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props; const { dispatch, onlyMedia } = this.props;
this.disconnect(); if (this.disconnect) {
this.disconnect();
}
dispatch(expandCommunityTimeline({ onlyMedia })); dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
} }
} }
@ -122,6 +136,10 @@ class CommunityTimeline extends React.PureComponent {
<ColumnSettingsContainer columnId={columnId} /> <ColumnSettingsContainer columnId={columnId} />
</ColumnHeader> </ColumnHeader>
<DismissableBanner id='community_timeline'>
<FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
</DismissableBanner>
<StatusListContainer <StatusListContainer
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`community_timeline-${columnId}`} scrollKey={`community_timeline-${columnId}`}
@ -132,7 +150,8 @@ class CommunityTimeline extends React.PureComponent {
/> />
<Helmet> <Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title> <title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet> </Helmet>
</Column> </Column>
); );

View File

@ -56,7 +56,7 @@ class ActionBar extends React.PureComponent {
return ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'> <div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='chevron-down' size={16} direction='right' /> <DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
</div> </div>
</div> </div>
); );

View File

@ -21,7 +21,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}> <Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={48} /> <Avatar account={this.props.account} size={46} />
</Permalink> </Permalink>
<div className='navigation-bar__profile'> <div className='navigation-bar__profile'>

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