Merge remote-tracking branch 'upstream/main' into develop

# Conflicts:
#	.github/workflows/build-image.yml
This commit is contained in:
Jeremy Kescher 2022-10-31 21:37:48 +01:00
commit 7f6215984b
No known key found for this signature in database
GPG key ID: 48DFE4BB15BA5940
744 changed files with 19888 additions and 24725 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

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

@ -62,7 +62,7 @@ module Admin
def export_data def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content| CSV.generate(headers: export_headers, write_headers: true) do |content|
DomainBlock.with_user_facing_limitations.each do |instance| DomainBlock.with_limitations.each do |instance|
content << [instance.domain, instance.severity, instance.reject_media, instance.reject_reports, instance.public_comment, instance.obfuscate] content << [instance.domain, instance.severity, instance.reject_media, instance.reject_reports, instance.public_comment, instance.obfuscate]
end end
end end

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

@ -60,14 +60,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
def reject def reject
authorize @account.user, :reject? authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
render json: @account, serializer: REST::Admin::AccountSerializer render_empty
end end
def destroy def destroy
authorize @account, :destroy? authorize @account, :destroy?
json = render_to_body json: @account, serializer: REST::Admin::AccountSerializer
Admin::AccountDeletionWorker.perform_async(@account.id) Admin::AccountDeletionWorker.perform_async(@account.id)
render json: json render_empty
end end
def unsensitive def unsensitive

View file

@ -35,20 +35,16 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
def create def create
authorize :canonical_email_block, :create? authorize :canonical_email_block, :create?
@canonical_email_block = CanonicalEmailBlock.create!(resource_params) @canonical_email_block = CanonicalEmailBlock.create!(resource_params)
log_action :create, @canonical_email_block log_action :create, @canonical_email_block
render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer
end end
def destroy def destroy
authorize @canonical_email_block, :destroy? authorize @canonical_email_block, :destroy?
@canonical_email_block.destroy! @canonical_email_block.destroy!
log_action :destroy, @canonical_email_block log_action :destroy, @canonical_email_block
render_empty
render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer
end end
private private

View file

@ -43,7 +43,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
authorize @domain_allow, :destroy? authorize @domain_allow, :destroy?
UnallowDomainService.new.call(@domain_allow) UnallowDomainService.new.call(@domain_allow)
log_action :destroy, @domain_allow log_action :destroy, @domain_allow
render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer render_empty
end end
private private

View file

@ -40,7 +40,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
def update def update
authorize @domain_block, :update? authorize @domain_block, :update?
@domain_block.update(domain_block_params) @domain_block.update(domain_block_params)
severity_changed = @domain_block.severity_changed? severity_changed = @domain_block.severity_changed?
@domain_block.save! @domain_block.save!
@ -53,7 +52,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
authorize @domain_block, :destroy? authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block) UnblockDomainService.new.call(@domain_block)
log_action :destroy, @domain_block log_action :destroy, @domain_block
render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer render_empty
end end
private private

View file

@ -39,11 +39,9 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController
def destroy def destroy
authorize @email_domain_block, :destroy? authorize @email_domain_block, :destroy?
@email_domain_block.destroy! @email_domain_block.destroy!
log_action :destroy, @email_domain_block log_action :destroy, @email_domain_block
render_empty
render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer
end end
private private

View file

@ -20,10 +20,8 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController
def create def create
authorize :ip_block, :create? authorize :ip_block, :create?
@ip_block = IpBlock.create!(resource_params) @ip_block = IpBlock.create!(resource_params)
log_action :create, @ip_block log_action :create, @ip_block
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end end
@ -39,20 +37,16 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController
def update def update
authorize @ip_block, :update? authorize @ip_block, :update?
@ip_block.update(resource_params) @ip_block.update(resource_params)
log_action :update, @ip_block log_action :update, @ip_block
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end end
def destroy def destroy
authorize @ip_block, :destroy? authorize @ip_block, :destroy?
@ip_block.destroy! @ip_block.destroy!
log_action :destroy, @ip_block log_action :destroy, @ip_block
render_empty
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end end
private private

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

@ -66,6 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
text: status_params[:status], text: status_params[:status],
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
language: status_params[:language],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
poll: status_params[:poll], poll: status_params[:poll],
content_type: status_params[:content_type] content_type: status_params[:content_type]
@ -79,6 +80,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

@ -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,19 @@ 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
@invite_code = invite_code
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

@ -97,7 +97,7 @@ module LanguagesHelper
lg: ['Ganda', 'Luganda'].freeze, lg: ['Ganda', 'Luganda'].freeze,
li: ['Limburgish', 'Limburgs'].freeze, li: ['Limburgish', 'Limburgs'].freeze,
ln: ['Lingala', 'Lingála'].freeze, ln: ['Lingala', 'Lingála'].freeze,
lo: ['Lao', 'ພາສາ'].freeze, lo: ['Lao', 'ລາວ'].freeze,
lt: ['Lithuanian', 'lietuvių kalba'].freeze, lt: ['Lithuanian', 'lietuvių kalba'].freeze,
lu: ['Luba-Katanga', 'Tshiluba'].freeze, lu: ['Luba-Katanga', 'Tshiluba'].freeze,
lv: ['Latvian', 'latviešu valoda'].freeze, lv: ['Latvian', 'latviešu valoda'].freeze,

View file

@ -4,6 +4,24 @@ import 'packs/public-path';
import { delegate } from '@rails/ujs'; import { delegate } from '@rails/ujs';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
const setAnnouncementEndsAttributes = (target) => {
const valid = target?.value && target?.validity?.valid;
const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
if (valid) {
element.classList.remove('optional');
element.required = true;
element.min = target.value;
} else {
element.classList.add('optional');
element.removeAttribute('required');
element.removeAttribute('min');
}
};
delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
setAnnouncementEndsAttributes(target);
});
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
const showSelectAll = () => { const showSelectAll = () => {
@ -143,6 +161,20 @@ const onChangeRegistrationMode = (target) => {
}); });
}; };
const convertUTCDateTimeToLocal = (value) => {
const date = new Date(value + 'Z');
const twoChars = (x) => (x.toString().padStart(2, '0'));
return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
};
const convertLocalDatetimeToUTC = (value) => {
const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
const match = re.exec(value);
const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
const fullISO8601 = date.toISOString();
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
};
delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
ready(() => { ready(() => {
@ -170,4 +202,26 @@ ready(() => {
e.target.href = url; e.target.href = url;
} }
}); });
[].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
if (element.value) {
element.value = convertUTCDateTimeToLocal(element.value);
}
if (element.placeholder) {
element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
}
});
delegate(document, 'form', 'submit', ({ target }) => {
[].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
}); });

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,19 +1,21 @@
import api from '../api'; import axios from 'axios';
import { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { defineMessages } from 'react-intl';
import api from 'flavours/glitch/api';
import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis'; import { tagHistory } from 'flavours/glitch/settings';
import { tagHistory } from '../settings';
import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
import resizeImage from 'flavours/glitch/utils/resize_image'; import resizeImage from 'flavours/glitch/utils/resize_image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { openModal } from './modal'; import { openModal } from './modal';
import { defineMessages } from 'react-intl'; 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_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND'; export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
@ -25,11 +27,13 @@ export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_PROCESSING = 'COMPOSE_UPLOAD_PROCESSING';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST'; export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS'; export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
@ -83,10 +87,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');
} }
}; };
@ -307,13 +309,16 @@ export function uploadCompose(files) {
if (status === 200) { if (status === 200) {
dispatch(uploadComposeSuccess(data, f)); dispatch(uploadComposeSuccess(data, f));
} else if (status === 202) { } else if (status === 202) {
dispatch(uploadComposeProcessing());
let tryCount = 1; let tryCount = 1;
const poll = () => { const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => { api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) { if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, f)); dispatch(uploadComposeSuccess(response.data, f));
} else if (response.status === 206) { } else if (response.status === 206) {
let retryAfter = (Math.log2(tryCount) || 1) * 1000; const retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1; tryCount += 1;
setTimeout(() => poll(), retryAfter); setTimeout(() => poll(), retryAfter);
} }
@ -328,6 +333,10 @@ export function uploadCompose(files) {
}; };
}; };
export const uploadComposeProcessing = () => ({
type: COMPOSE_UPLOAD_PROCESSING,
});
export const uploadThumbnail = (id, file) => (dispatch, getState) => { export const uploadThumbnail = (id, file) => (dispatch, getState) => {
dispatch(uploadThumbnailRequest()); dispatch(uploadThumbnailRequest());
@ -472,8 +481,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,
@ -481,14 +490,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),
@ -499,9 +508,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 });
@ -511,16 +522,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',
@ -531,9 +542,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

@ -29,7 +29,8 @@ export function clearSearch() {
export function submitSearch() { export function submitSearch() {
return (dispatch, getState) => { return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']); const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) { if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, ''));
@ -41,7 +42,7 @@ export function submitSearch() {
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params: { params: {
q: value, q: value,
resolve: true, resolve: signedIn,
limit: 10, limit: 10,
}, },
}).then(response => { }).then(response => {

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

@ -156,8 +156,8 @@ export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => ex
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done); export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, 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 expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, 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`, `/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

@ -70,6 +70,8 @@ export default class Avatar extends React.PureComponent {
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
style={style} style={style}
data-avatar-of={account && `@${account.get('acct')}`} data-avatar-of={account && `@${account.get('acct')}`}
role='img'
aria-label={account?.get('acct')}
/> />
); );
} }

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 'flavours/glitch/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

@ -4,6 +4,7 @@ import { FormattedMessage } from 'react-intl';
import { source_url } from 'flavours/glitch/initial_state'; import { source_url } from 'flavours/glitch/initial_state';
import { preferencesLink } from 'flavours/glitch/utils/backend_links'; import { preferencesLink } from 'flavours/glitch/utils/backend_links';
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 {
@ -122,6 +123,10 @@ export default class ErrorBoundary extends React.PureComponent {
)} )}
</ul> </ul>
</div> </div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
import React from 'react'; import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink'; import Permalink from './permalink';
@ -9,10 +9,6 @@ import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton'; import Skeleton from 'flavours/glitch/components/skeleton';
import classNames from 'classnames'; import classNames from 'classnames';
const messages = defineMessages({
totalVolume: { id: 'hashtag.total_volume', defaultMessage: 'Total volume in the last {days, plural, one {day} other {{days} days}}' },
});
class SilentErrorBoundary extends React.Component { class SilentErrorBoundary extends React.Component {
static propTypes = { static propTypes = {
@ -60,7 +56,6 @@ export const ImmutableHashtag = ({ hashtag }) => (
href={hashtag.get('url')} href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`} to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1} people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()} history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/> />
); );
@ -69,39 +64,52 @@ ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired, hashtag: ImmutablePropTypes.map.isRequired,
}; };
const Hashtag = injectIntl(({ name, href, to, people, uses, history, className, intl }) => ( 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>
<abbr className='trends__item__current' title={intl.formatMessage(messages.totalVolume, { days: 2 })}> {typeof uses !== 'undefined' && (
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />} <div className='trends__item__current'>
<span className='trends__item__current__asterisk'>*</span> <ShortNumber value={uses} />
</abbr> </div>
)}
<div className='trends__item__sparkline'> {withGraph && (
<SilentErrorBoundary> <div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}> <SilentErrorBoundary>
<SparklinesCurve style={{ fill: 'none' }} /> <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
</Sparklines> <SparklinesCurve style={{ fill: 'none' }} />
</SilentErrorBoundary> </Sparklines>
</div> </SilentErrorBoundary>
</div>
)}
</div> </div>
)); );
Hashtag.propTypes = { Hashtag.propTypes = {
name: PropTypes.string, name: PropTypes.string,
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 'flavours/glitch/images/elephant_ui_disappointed.svg'; import illustration from 'flavours/glitch/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 'flavours/glitch/initial_state';
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
import AccountNavigation from 'flavours/glitch/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

@ -1,19 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { domain } from 'flavours/glitch/initial_state'; import React from 'react';
import { fetchServer } from 'flavours/glitch/actions/server'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Account from 'flavours/glitch/containers/account_container'; import { fetchServer } from 'flavours/glitch/actions/server';
import ShortNumber from 'flavours/glitch/components/short_number'; import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton'; import Skeleton from 'flavours/glitch/components/skeleton';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import Account from 'flavours/glitch/containers/account_container';
import { domain } from 'flavours/glitch/initial_state';
import Image from 'flavours/glitch/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

@ -124,6 +124,9 @@ export default 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 nofollow noreferrer');
try { try {
if (tagLinks && isLinkMisleading(link)) { if (tagLinks && isLinkMisleading(link)) {
// Add a tag besides the link to display its origin // Add a tag besides the link to display its origin
@ -149,9 +152,6 @@ export default class StatusContent extends React.PureComponent {
if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
} }
} }
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
} }
} }

View file

@ -1,22 +1,25 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from 'flavours/glitch/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 configureStore from 'flavours/glitch/store/configureStore';
import UI from 'flavours/glitch/features/ui'; import UI from 'flavours/glitch/features/ui';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { hydrateStore } from 'flavours/glitch/actions/store'; import { hydrateStore } from 'flavours/glitch/actions/store';
import { connectUserStream } from 'flavours/glitch/actions/streaming';
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings'; import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
import { IntlProvider, addLocaleData } from 'react-intl'; import { connectUserStream } from 'flavours/glitch/actions/streaming';
import { getLocale } from 'locales';
import initialState from 'flavours/glitch/initial_state';
import ErrorBoundary from 'flavours/glitch/components/error_boundary'; import ErrorBoundary from 'flavours/glitch/components/error_boundary';
import initialState, { title as siteTitle } from 'flavours/glitch/initial_state';
import { getLocale } from '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);
store.dispatch(hydrateAction); store.dispatch(hydrateAction);
@ -78,15 +81,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

@ -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 'flavours/glitch/components/column';
import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
import { Helmet } from 'react-helmet';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
import Account from 'flavours/glitch/containers/account_container';
import Skeleton from 'flavours/glitch/components/skeleton';
import Icon from 'flavours/glitch/components/icon';
import classNames from 'classnames';
import Image from 'flavours/glitch/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 'flavours/glitch/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')}
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

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me, title, domain } from 'flavours/glitch/initial_state'; import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state';
import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links'; import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
@ -19,7 +19,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' },
@ -273,7 +273,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;
@ -352,7 +354,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 'flavours/glitch/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 'flavours/glitch/features/account/containers/featured_tags_container';
import { normalizeForLookup } from 'flavours/glitch/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

@ -15,9 +15,10 @@ import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import LoadMore from 'flavours/glitch/components/load_more'; import LoadMore from 'flavours/glitch/components/load_more';
import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { normalizeForLookup } from 'flavours/glitch/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

@ -24,6 +24,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from 'flavours/glitch/initial_state'; import { unfollowModal } from 'flavours/glitch/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' },
}); });
@ -43,7 +44,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> }} />,
@ -53,6 +54,14 @@ 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')));
} }

View file

@ -17,19 +17,22 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import TimelineHint from 'flavours/glitch/components/timeline_hint'; import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from './components/limited_account_hint'; import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors'; import { getAccountHidden } from 'flavours/glitch/selectors';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { fetchFeaturedTags } from '../../actions/featured_tags';
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,
@ -37,7 +40,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'], ImmutableList()), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], ImmutableList()),
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),
@ -60,6 +63,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,
@ -77,14 +81,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 }));
} }
componentDidMount () { componentDidMount () {
@ -98,12 +104,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 }));
} }
} }
@ -126,7 +137,7 @@ 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 }));
} }
setRef = c => { setRef = c => {
@ -136,7 +147,13 @@ class AccountTimeline extends ImmutablePureComponent {
render () { render () {
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, 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} />
@ -145,14 +162,6 @@ class AccountTimeline extends ImmutablePureComponent {
); );
} }
if (!statusIds && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage; let emptyMessage;
const forceEmptyState = suspended || hidden; const forceEmptyState = suspended || hidden;
@ -174,7 +183,7 @@ class AccountTimeline extends ImmutablePureComponent {
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> <ProfileColumnHeader onClick={this.handleHeaderClick} 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 { debounce } from 'lodash'; import { debounce } from 'lodash';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks'; import PropTypes from 'prop-types';
import Column from 'flavours/glitch/features/ui/components/column'; import React from 'react';
import ColumnHeader from 'flavours/glitch/components/column_header'; import { Helmet } from 'react-helmet';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import StatusList from 'flavours/glitch/components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import ColumnHeader from 'flavours/glitch/components/column_header';
import StatusList from 'flavours/glitch/components/status_list';
import Column from 'flavours/glitch/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 'flavours/glitch/initial_state';
import { fetchServer } from 'flavours/glitch/actions/server';
const mapStateToProps = state => ({
message: state.getIn(['server', 'server', 'registrations', 'message']),
});
export default @connect(mapStateToProps)
class ClosedRegistrationsModal extends ImmutablePureComponent {
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchServer());
}
render () {
let closedRegistrationsMessage;
if (this.props.message) {
closedRegistrationsMessage = (
<p
className='prose'
dangerouslySetInnerHTML={{ __html: this.props.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 'flavours/glitch/actions/col
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/initial_state'; import { domain } from 'flavours/glitch/initial_state';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
@ -138,6 +139,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}`}
@ -149,7 +154,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

@ -21,6 +21,7 @@ import Motion from '../../ui/util/optional_motion';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
}); });
class SearchPopout extends React.PureComponent { class SearchPopout extends React.PureComponent {
@ -62,6 +63,7 @@ class Search extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object.isRequired, router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
}; };
static propTypes = { static propTypes = {
@ -137,6 +139,7 @@ class Search extends React.PureComponent {
render () { render () {
const { intl, value, submitted } = this.props; const { intl, value, submitted } = this.props;
const { expanded } = this.state; const { expanded } = this.state;
const { signedIn } = this.context.identity;
const hasValue = value.length > 0 || submitted; const hasValue = value.length > 0 || submitted;
return ( return (
@ -147,7 +150,7 @@ class Search extends React.PureComponent {
ref={this.setRef} ref={this.setRef}
className='search__input' className='search__input'
type='text' type='text'
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''} value={value || ''}
onChange={this.handleChange} onChange={this.handleChange}
onKeyUp={this.handleKeyUp} onKeyUp={this.handleKeyUp}

View file

@ -4,7 +4,6 @@ import UploadProgressContainer from '../containers/upload_progress_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import UploadContainer from '../containers/upload_container'; import UploadContainer from '../containers/upload_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container';
import { FormattedMessage } from 'react-intl';
export default class UploadForm extends ImmutablePureComponent { export default class UploadForm extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -16,7 +15,7 @@ export default class UploadForm extends ImmutablePureComponent {
return ( return (
<div className='composer--upload_form'> <div className='composer--upload_form'>
<UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} /> <UploadProgressContainer />
{mediaIds.size > 0 && ( {mediaIds.size > 0 && (
<div className='content'> <div className='content'>

View file

@ -3,26 +3,34 @@ import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import { FormattedMessage } from 'react-intl';
export default class UploadProgress extends React.PureComponent { export default class UploadProgress extends React.PureComponent {
static propTypes = { static propTypes = {
active: PropTypes.bool, active: PropTypes.bool,
progress: PropTypes.number, progress: PropTypes.number,
icon: PropTypes.string.isRequired, isProcessing: PropTypes.bool,
message: PropTypes.node.isRequired,
}; };
render () { render () {
const { active, progress, icon, message } = this.props; const { active, progress, isProcessing } = this.props;
if (!active) { if (!active) {
return null; return null;
} }
let message;
if (isProcessing) {
message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
} else {
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
}
return ( return (
<div className='composer--upload_form--progress'> <div className='composer--upload_form--progress'>
<Icon id={icon} /> <Icon id='upload' />
<div className='message'> <div className='message'>
{message} {message}

View file

@ -4,6 +4,7 @@ import UploadProgress from '../components/upload_progress';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
active: state.getIn(['compose', 'is_uploading']), active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress']), progress: state.getIn(['compose', 'progress']),
isProcessing: state.getIn(['compose', 'is_processing']),
}); });
export default connect(mapStateToProps)(UploadProgress); export default connect(mapStateToProps)(UploadProgress);

View file

@ -14,6 +14,8 @@ import SearchResultsContainer from './containers/search_results_container';
import { me, mascot } from 'flavours/glitch/initial_state'; import { me, mascot } from 'flavours/glitch/initial_state';
import { cycleElefriendCompose } from 'flavours/glitch/actions/compose'; import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
import HeaderContainer from './containers/header_container'; import HeaderContainer from './containers/header_container';
import Column from 'flavours/glitch/components/column';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
@ -21,7 +23,7 @@ const messages = defineMessages({
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
elefriend: state.getIn(['compose', 'elefriend']), elefriend: state.getIn(['compose', 'elefriend']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage, showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
@ -44,7 +46,6 @@ class Compose extends React.PureComponent {
static propTypes = { static propTypes = {
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
isSearchPage: PropTypes.bool,
elefriend: PropTypes.number, elefriend: PropTypes.number,
onClickElefriend: PropTypes.func, onClickElefriend: PropTypes.func,
onMount: PropTypes.func, onMount: PropTypes.func,
@ -53,19 +54,11 @@ class Compose extends React.PureComponent {
}; };
componentDidMount () { componentDidMount () {
const { isSearchPage } = this.props; this.props.onMount();
if (!isSearchPage) {
this.props.onMount();
}
} }
componentWillUnmount () { componentWillUnmount () {
const { isSearchPage } = this.props; this.props.onUnmount();
if (!isSearchPage) {
this.props.onUnmount();
}
} }
render () { render () {
@ -74,37 +67,49 @@ class Compose extends React.PureComponent {
intl, intl,
multiColumn, multiColumn,
onClickElefriend, onClickElefriend,
isSearchPage,
showSearch, showSearch,
} = this.props; } = this.props;
const computedClass = classNames('drawer', `mbstobon-${elefriend}`); const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
return ( if (multiColumn) {
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}> return (
{multiColumn && <HeaderContainer />} <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
<HeaderContainer />
{(multiColumn || isSearchPage) && <SearchContainer />} {multiColumn && <SearchContainer />}
<div className='drawer__pager'> <div className='drawer__pager'>
{!isSearchPage && <div className='drawer__inner'> <div className='drawer__inner'>
<NavigationContainer /> <NavigationContainer />
<ComposeFormContainer /> <ComposeFormContainer />
<div className='drawer__inner__mastodon'> <div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />} {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div>
</div>}
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div> </div>
)} </div>
</Motion>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div>
)}
</Motion>
</div>
</div> </div>
</div> );
}
return (
<Column>
<NavigationContainer />
<ComposeFormContainer />
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
); );
} }

View file

@ -1,15 +1,16 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import React from 'react';
import { Helmet } from 'react-helmet';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
import { connectDirectStream } from 'flavours/glitch/actions/streaming';
import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/components/column'; import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import { expandDirectTimeline } from 'flavours/glitch/actions/timelines'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from 'flavours/glitch/actions/streaming';
import ConversationsListContainer from './containers/conversations_list_container'; import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({ const messages = defineMessages({
@ -143,6 +144,11 @@ class DirectTimeline extends React.PureComponent {
</ColumnHeader> </ColumnHeader>
{contents} {contents}
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column> </Column>
); );
} }

View file

@ -24,7 +24,7 @@ import classNames from 'classnames';
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_short', defaultMessage: 'Unblock' }, unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },

View file

@ -13,7 +13,6 @@ import RadioButton from 'flavours/glitch/components/radio_button';
import LoadMore from 'flavours/glitch/components/load_more'; import LoadMore from 'flavours/glitch/components/load_more';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { title } from 'flavours/glitch/initial_state';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
@ -169,7 +168,8 @@ class Directory extends React.PureComponent {
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea} {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
<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

@ -11,6 +11,7 @@ import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_bloc
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' }, heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
@ -59,6 +60,7 @@ class Blocks extends ImmutablePureComponent {
return ( return (
<Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='domain_blocks' scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
@ -70,6 +72,10 @@ class Blocks extends ImmutablePureComponent {
<DomainContainer key={domain} domain={domain} />, <DomainContainer key={domain} domain={domain} />,
)} )}
</ScrollableList> </ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column> </Column>
); );
} }

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