diff --git a/.circleci/config.yml b/.circleci/config.yml index 107ed1248..8c8b411df 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -177,8 +177,7 @@ jobs: steps: - *attach_workspace - run: bundle exec i18n-tasks check-normalized - - run: bundle exec i18n-tasks unused - - run: bundle exec i18n-tasks missing -t plural + - run: bundle exec i18n-tasks unused -l en - run: bundle exec i18n-tasks check-consistent-interpolations workflows: diff --git a/.codeclimate.yml b/.codeclimate.yml index a70459764..571507a54 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -30,8 +30,8 @@ plugins: channel: eslint-5 rubocop: enabled: true - channel: rubocop-0-54 - scss-lint: + channel: rubocop-0-71 + sass-lint: enabled: true exclude_patterns: - spec/ diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 000000000..07929aa07 --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,10 @@ +version: 1 + +update_configs: + - package_manager: "ruby:bundler" + directory: "/" + update_schedule: "weekly" + + - package_manager: "javascript" + directory: "/" + update_schedule: "weekly" diff --git a/.env.production.sample b/.env.production.sample index efb5661fd..3388d380a 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -10,6 +10,7 @@ DB_NAME=postgres DB_PASS= DB_PORT=5432 # Optional ElasticSearch configuration +# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set) # ES_ENABLED=true # ES_HOST=es # ES_PORT=9200 @@ -156,6 +157,24 @@ STREAMING_CLUSTER_NUM=1 # Maximum allowed character count # MAX_TOOT_CHARS=500 +# Maximum number of pinned posts +# MAX_PINNED_TOOTS=5 + +# Maximum allowed bio characters +# MAX_BIO_CHARS=500 + +# Maximim number of profile fields allowed +# MAX_PROFILE_FIELDS=4 + +# Maximum allowed display name characters +# MAX_DISPLAY_NAME_CHARS=30 + +# Maximum image and video/audio upload sizes +# Units are in bytes +# 1048576 bytes equals 1 megabyte +# MAX_IMAGE_SIZE=8388608 +# MAX_VIDEO_SIZE=41943040 + # LDAP authentication (optional) # LDAP_ENABLED=true # LDAP_HOST=localhost diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..91ee92a2e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: mastodon +open_collective: mastodon diff --git a/.rubocop.yml b/.rubocop.yml index f1095e022..8bd4c867f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,6 @@ +require: + - rubocop-rails + AllCops: TargetRubyVersion: 2.3 Exclude: @@ -82,6 +85,9 @@ Rails/Exit: - 'lib/mastodon/*' - 'lib/cli.rb' +Rails/HelperInstanceVariable: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false diff --git a/.sass-lint.yml b/.sass-lint.yml new file mode 100644 index 000000000..a84adff3f --- /dev/null +++ b/.sass-lint.yml @@ -0,0 +1,37 @@ +# Linter Documentation: +# https://github.com/sasstools/sass-lint/tree/v1.13.1/docs/options + +files: + include: app/javascript/styles/**/*.scss + ignore: + - app/javascript/styles/mastodon/reset.scss + +rules: + # Disallows + no-color-literals: 0 + no-css-comments: 0 + no-duplicate-properties: 0 + no-ids: 0 + no-important: 0 + no-mergeable-selectors: 0 + no-misspelled-properties: 0 + no-qualifying-elements: 0 + no-transition-all: 0 + no-vendor-prefixes: 0 + + # Nesting + force-element-nesting: 0 + force-attribute-nesting: 0 + force-pseudo-nesting: 0 + + # Name Formats + class-name-format: 0 + leading-zero: 0 + + # Style Guide + attribute-quotes: 0 + hex-length: 0 + indentation: 0 + nesting-depth: 0 + property-sort-order: 0 + quotes: 0 diff --git a/.scss-lint.yml b/.scss-lint.yml deleted file mode 100644 index 5d7cc4da5..000000000 --- a/.scss-lint.yml +++ /dev/null @@ -1,264 +0,0 @@ -# Linter Documentation: -# https://github.com/brigade/scss-lint/blob/v0.42.2/lib/scss_lint/linter/README.md - -scss_files: 'app/javascript/styles/**/*.scss' - -exclude: - - app/javascript/styles/reset.scss - -linters: - # Reports when you use improper spacing around ! (the "bang") in !default, - # !global, !important, and !optional flags. - BangFormat: - enabled: false - - # Whether or not to prefer `border: 0` over `border: none`. - BorderZero: - enabled: false - - # Reports when you define a rule set using a selector with chained classes - # (a.k.a. adjoining classes). - ChainedClasses: - enabled: false - - # Prefer hexadecimal color codes over color keywords. - # (e.g. `color: green` is a color keyword) - ColorKeyword: - enabled: false - - # Prefer color literals (keywords or hexadecimal codes) to be used only in - # variable declarations. They should be referred to via variables everywhere - # else. - ColorVariable: - enabled: true - - # Which form of comments to prefer in CSS. - Comment: - enabled: false - - # Reports @debug statements (which you probably left behind accidentally). - DebugStatement: - enabled: false - - # Rule sets should be ordered as follows: - # - @extend declarations - # - @include declarations without inner @content - # - properties, @include declarations with inner @content - # - nested rule sets. - DeclarationOrder: - enabled: false - - # `scss-lint:disable` control comments should be preceded by a comment - # explaining why these linters are being disabled for this file. - # See https://github.com/brigade/scss-lint#disabling-linters-via-source for - # more information. - DisableLinterReason: - enabled: true - - # Reports when you define the same property twice in a single rule set. - DuplicateProperty: - enabled: false - - # Separate rule, function, and mixin declarations with empty lines. - EmptyLineBetweenBlocks: - enabled: true - - # Reports when you have an empty rule set. - EmptyRule: - enabled: true - - # Reports when you have an @extend directive. - ExtendDirective: - enabled: false - - # Files should always have a final newline. This results in better diffs - # when adding lines to the file, since SCM systems such as git won't - # think that you touched the last line. - FinalNewline: - enabled: false - - # HEX colors should use three-character values where possible. - HexLength: - enabled: false - - # HEX color values should use lower-case colors to differentiate between - # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. - HexNotation: - enabled: true - - # Avoid using ID selectors. - IdSelector: - enabled: false - - # The basenames of @imported SCSS partials should not begin with an - # underscore and should not include the filename extension. - ImportPath: - enabled: false - - # Avoid using !important in properties. It is usually indicative of a - # misunderstanding of CSS specificity and can lead to brittle code. - ImportantRule: - enabled: false - - # Indentation should always be done in increments of 2 spaces. - Indentation: - enabled: true - width: 2 - - # Don't write leading zeros for numeric values with a decimal point. - LeadingZero: - enabled: false - - # Reports when you define the same selector twice in a single sheet. - MergeableSelector: - enabled: false - - # Functions, mixins, variables, and placeholders should be declared - # with all lowercase letters and hyphens instead of underscores. - NameFormat: - enabled: false - - # Avoid nesting selectors too deeply. - NestingDepth: - enabled: false - - # Always use placeholder selectors in @extend. - PlaceholderInExtend: - enabled: false - - # Sort properties in a strict order. - PropertySortOrder: - enabled: false - - # Reports when you use an unknown or disabled CSS property - # (ignoring vendor-prefixed properties). - PropertySpelling: - enabled: false - - # Configure which units are allowed for property values. - PropertyUnits: - enabled: false - - # Pseudo-elements, like ::before, and ::first-letter, should be declared - # with two colons. Pseudo-classes, like :hover and :first-child, should - # be declared with one colon. - PseudoElement: - enabled: true - - # Avoid qualifying elements in selectors (also known as "tag-qualifying"). - QualifyingElement: - enabled: false - - # Don't write selectors with a depth of applicability greater than 3. - SelectorDepth: - enabled: false - - # Selectors should always use hyphenated-lowercase, rather than camelCase or - # snake_case. - SelectorFormat: - enabled: false - convention: hyphenated_lowercase - - # Prefer the shortest shorthand form possible for properties that support it. - Shorthand: - enabled: true - - # Each property should have its own line, except in the special case of - # single line rulesets. - SingleLinePerProperty: - enabled: true - allow_single_line_rule_sets: true - - # Split selectors onto separate lines after each comma, and have each - # individual selector occupy a single line. - SingleLinePerSelector: - enabled: true - - # Commas in lists should be followed by a space. - SpaceAfterComma: - enabled: false - - # Properties should be formatted with a single space separating the colon - # from the property's value. - SpaceAfterPropertyColon: - enabled: true - - # Properties should be formatted with no space between the name and the - # colon. - SpaceAfterPropertyName: - enabled: true - - # Variables should be formatted with a single space separating the colon - # from the variable's value. - SpaceAfterVariableColon: - enabled: true - - # Variables should be formatted with no space between the name and the - # colon. - SpaceAfterVariableName: - enabled: false - - # Operators should be formatted with a single space on both sides of an - # infix operator. - SpaceAroundOperator: - enabled: true - - # Opening braces should be preceded by a single space. - SpaceBeforeBrace: - enabled: true - - # Parentheses should not be padded with spaces. - SpaceBetweenParens: - enabled: false - - # Enforces that string literals should be written with a consistent form - # of quotes (single or double). - StringQuotes: - enabled: false - - # Property values, @extend, @include, and @import directives, and variable - # declarations should always end with a semicolon. - TrailingSemicolon: - enabled: true - - # Reports lines containing trailing whitespace. - TrailingWhitespace: - enabled: true - - # Don't write trailing zeros for numeric values with a decimal point. - TrailingZero: - enabled: false - - # Don't use the `all` keyword to specify transition properties. - TransitionAll: - enabled: false - - # Numeric values should not contain unnecessary fractional portions. - UnnecessaryMantissa: - enabled: false - - # Do not use parent selector references (&) when they would otherwise - # be unnecessary. - UnnecessaryParentReference: - enabled: false - - # URLs should be valid and not contain protocols or domain names. - UrlFormat: - enabled: true - - # URLs should always be enclosed within quotes. - UrlQuotes: - enabled: true - - # Properties, like color and font, are easier to read and maintain - # when defined using variables rather than literals. - VariableForProperty: - enabled: false - - # Avoid vendor prefixes. Or rather: don't write them yourself. - VendorPrefix: - enabled: false - - # Omit length units on zero values, e.g. `0px` vs. `0`. - ZeroUnit: - enabled: true diff --git a/.yarnclean b/.yarnclean index f2de52869..0cc2b50d7 100644 --- a/.yarnclean +++ b/.yarnclean @@ -43,4 +43,4 @@ Gruntfile.js # for specific ignore !.svgo.yml - +!sass-lint/**/*.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 17626e027..539fec531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,183 @@ Changelog All notable changes to this project will be documented in this file. +## [2.9.2] - 2019-06-22 +### Added + +- Add `short_description` and `approval_required` to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/11146)) + +### Changed + +- Change camera icon to paperclip icon in upload form ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11149)) + +### Fixed + +- Fix audio-only OGG and WebM files not being processed as such ([Gargron](https://github.com/tootsuite/mastodon/pull/11151)) +- Fix audio not being downloaded from remote servers ([Gargron](https://github.com/tootsuite/mastodon/pull/11145)) + +## [2.9.1] - 2019-06-22 +### Added + +- Add moderation API ([Gargron](https://github.com/tootsuite/mastodon/pull/9387)) +- Add audio uploads ([Gargron](https://github.com/tootsuite/mastodon/pull/11123), [Gargron](https://github.com/tootsuite/mastodon/pull/11141)) + +### Changed + +- Change domain blocks to automatically support subdomains ([Gargron](https://github.com/tootsuite/mastodon/pull/11138)) +- Change Nanobox configuration to bring it up to date ([danhunsaker](https://github.com/tootsuite/mastodon/pull/11083)) + +### Removed + +- Remove expensive counters from federation page in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11139)) + +### Fixed + +- Fix converted media being saved with original extension and mime type ([Gargron](https://github.com/tootsuite/mastodon/pull/11130)) +- Fix layout of identity proofs settings ([acid-chicken](https://github.com/tootsuite/mastodon/pull/11126)) +- Fix active scope only returning suspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/11111)) +- Fix sanitizer making block level elements unreadable ([Gargron](https://github.com/tootsuite/mastodon/pull/10836)) +- Fix label for site theme not being translated in admin UI ([palindromordnilap](https://github.com/tootsuite/mastodon/pull/11121)) +- Fix statuses not being filtered irreversibly in web UI under some circumstances ([ThibG](https://github.com/tootsuite/mastodon/pull/11113)) +- Fix scrolling behaviour in compose form ([ThibG](https://github.com/tootsuite/mastodon/pull/11093)) + +## [2.9.0] - 2019-06-13 +### Added + +- **Add single-column mode in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10807), [Gargron](https://github.com/tootsuite/mastodon/pull/10848), [Gargron](https://github.com/tootsuite/mastodon/pull/11003), [Gargron](https://github.com/tootsuite/mastodon/pull/10961), [Hanage999](https://github.com/tootsuite/mastodon/pull/10915), [noellabo](https://github.com/tootsuite/mastodon/pull/10917), [abcang](https://github.com/tootsuite/mastodon/pull/10859), [Gargron](https://github.com/tootsuite/mastodon/pull/10820), [Gargron](https://github.com/tootsuite/mastodon/pull/10835), [Gargron](https://github.com/tootsuite/mastodon/pull/10809), [Gargron](https://github.com/tootsuite/mastodon/pull/10963), [noellabo](https://github.com/tootsuite/mastodon/pull/10883), [Hanage999](https://github.com/tootsuite/mastodon/pull/10839)) +- Add waiting time to the list of pending accounts in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10985)) +- Add a keyboard shortcut to hide/show media in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10647), [Gargron](https://github.com/tootsuite/mastodon/pull/10838), [ThibG](https://github.com/tootsuite/mastodon/pull/10872)) +- Add `account_id` param to `GET /api/v1/notifications` ([pwoolcoc](https://github.com/tootsuite/mastodon/pull/10796)) +- Add confirmation modal for unboosting toots in web UI ([aurelien-reeves](https://github.com/tootsuite/mastodon/pull/10287)) +- Add emoji suggestions to content warning and poll option fields in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10555)) +- Add `source` attribute to response of `DELETE /api/v1/statuses/:id` ([ThibG](https://github.com/tootsuite/mastodon/pull/10669)) +- Add some caching for HTML versions of public status pages ([ThibG](https://github.com/tootsuite/mastodon/pull/10701)) +- Add button to conveniently copy OAuth code ([ThibG](https://github.com/tootsuite/mastodon/pull/11065)) + +### Changed + +- **Change default layout to single column in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10847)) +- **Change light theme** ([Gargron](https://github.com/tootsuite/mastodon/pull/10992), [Gargron](https://github.com/tootsuite/mastodon/pull/10996), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10754), [Gargron](https://github.com/tootsuite/mastodon/pull/10845)) +- **Change preferences page into appearance, notifications, and other** ([Gargron](https://github.com/tootsuite/mastodon/pull/10977), [Gargron](https://github.com/tootsuite/mastodon/pull/10988)) +- Change priority of delete activity forwards for replies and reblogs ([Gargron](https://github.com/tootsuite/mastodon/pull/11002)) +- Change Mastodon logo to use primary text color of the given theme ([Gargron](https://github.com/tootsuite/mastodon/pull/10994)) +- Change reblogs counter to be updated when boosted privately ([Gargron](https://github.com/tootsuite/mastodon/pull/10964)) +- Change bio limit from 160 to 500 characters ([trwnh](https://github.com/tootsuite/mastodon/pull/10790)) +- Change API rate limiting to reduce allowed unauthenticated requests ([ThibG](https://github.com/tootsuite/mastodon/pull/10860), [hinaloe](https://github.com/tootsuite/mastodon/pull/10868), [mayaeh](https://github.com/tootsuite/mastodon/pull/10867)) +- Change help text of `tootctl emoji import` command to specify a gzipped TAR archive is required ([dariusk](https://github.com/tootsuite/mastodon/pull/11000)) +- Change web UI to hide poll options behind content warnings ([ThibG](https://github.com/tootsuite/mastodon/pull/10983)) +- Change silencing to ensure local effects and remote effects are the same for silenced local users ([ThibG](https://github.com/tootsuite/mastodon/pull/10575)) +- Change `tootctl domains purge` to remove custom emoji as well ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10721)) +- Change Docker image to keep `apt` working ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10830)) + +### Removed + +- Remove `dist-upgrade` from Docker image ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10822)) + +### Fixed + +- Fix RTL layout not being RTL within the columns area in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10990)) +- Fix display of alternative text when a media attachment is not available in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10981)) +- Fix not being able to directly switch between list timelines in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10973)) +- Fix media sensitivity not being maintained in delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10980)) +- Fix emoji picker being always displayed in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/10979), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10801), [wcpaez](https://github.com/tootsuite/mastodon/pull/10978)) +- Fix potential private status leak through caching ([ThibG](https://github.com/tootsuite/mastodon/pull/10969)) +- Fix refreshing featured toots when the new collection is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10971)) +- Fix undoing domain block also undoing individual moderation on users from before the domain block ([ThibG](https://github.com/tootsuite/mastodon/pull/10660)) +- Fix time not being local in the audit log ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10751)) +- Fix statuses removed by moderation re-appearing on subsequent fetches ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10732)) +- Fix misattribution of inlined announces if `attributedTo` isn't present in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10967)) +- Fix `GET /api/v1/polls/:id` not requiring authentication for non-public polls ([Gargron](https://github.com/tootsuite/mastodon/pull/10960)) +- Fix handling of blank poll options in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10946)) +- Fix avatar preview aspect ratio on edit profile page ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10931)) +- Fix web push notifications not being sent for polls ([ThibG](https://github.com/tootsuite/mastodon/pull/10864)) +- Fix cut off letters in last paragraph of statuses in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/10821)) +- Fix list not being automatically unpinned when it returns 404 in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11045)) +- Fix login sometimes redirecting to paths that are not pages ([Gargron](https://github.com/tootsuite/mastodon/pull/11019)) + +## [2.8.4] - 2019-05-24 +### Fixed + +- Fix delivery not retrying on some inbox errors that should be retriable ([ThibG](https://github.com/tootsuite/mastodon/pull/10812)) +- Fix unnecessary 5 minute cooldowns on signature verifications in some cases ([ThibG](https://github.com/tootsuite/mastodon/pull/10813)) +- Fix possible race condition when processing statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10815)) + +### Security + +- Require specific OAuth scopes for specific endpoints of the streaming API, instead of merely requiring a token for all endpoints, and allow using WebSockets protocol negotiation to specify the access token instead of using a query string ([ThibG](https://github.com/tootsuite/mastodon/pull/10818)) + +## [2.8.3] - 2019-05-19 +### Added + +- Add `og:image:alt` OpenGraph tag ([BenLubar](https://github.com/tootsuite/mastodon/pull/10779)) +- Add clickable area below avatar in statuses in web UI ([Dar13](https://github.com/tootsuite/mastodon/pull/10766)) +- Add crossed-out eye icon on account gallery in web UI ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10715)) +- Add media description tooltip to thumbnails in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10713)) + +### Changed + +- Change "mark as sensitive" button into a checkbox for clarity ([ThibG](https://github.com/tootsuite/mastodon/pull/10748)) + +### Fixed + +- Fix bug allowing users to publicly boost their private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10775), [ThibG](https://github.com/tootsuite/mastodon/pull/10783)) +- Fix performance in formatter by a little ([ThibG](https://github.com/tootsuite/mastodon/pull/10765)) +- Fix some colors in the light theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10754)) +- Fix some colors of the high contrast theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10711)) +- Fix ambivalent active state of poll refresh button in web UI ([MaciekBaron](https://github.com/tootsuite/mastodon/pull/10720)) +- Fix duplicate posting being possible from web UI ([hinaloe](https://github.com/tootsuite/mastodon/pull/10785)) +- Fix "invited by" not showing up in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10791)) + +## [2.8.2] - 2019-05-05 +### Added + +- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/tootsuite/mastodon/pull/10698)) + +### Fixed + +- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/tootsuite/mastodon/pull/10702)) +- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/tootsuite/mastodon/pull/10700)) +- Fix unexpected CSS animations in some browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/10699)) +- Fix closing video modal scrolling timelines to top ([ThibG](https://github.com/tootsuite/mastodon/pull/10695)) + +## [2.8.1] - 2019-05-04 +### Added + +- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663)) +- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676)) +- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603)) +- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600)) +- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666)) +- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) + +### Changed + +- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) +- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) +- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683)) +- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655)) +- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682)) +- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674)) + +### Fixed + +- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621)) +- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684)) +- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614)) +- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593)) +- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657)) +- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622)) +- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632)) +- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633)) +- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624)) +- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623)) +- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553)) +- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605)) +- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565)) +- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549)) +- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604)) +- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594)) +- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586)) + ## [2.8.0] - 2019-04-10 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4b20fe971..cc0a7f51b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at beatrix.bitrot@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at glitch-abuse@sitedethib.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eceaf142..4019b1eed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,9 +52,9 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https:// ## Translations -You can submit translations via [Weblate](https://weblate.joinmastodon.org/). They are periodically merged into the codebase. +You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase. -[![Mastodon translation statistics by language](https://weblate.joinmastodon.org/widgets/mastodon/-/multi-auto.svg)](https://weblate.joinmastodon.org/) +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] ## Pull requests diff --git a/Dockerfile b/Dockerfile index 6373172fc..3acbc9d4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ SHELL ["bash", "-c"] ENV NODE_VER="8.15.0" RUN echo "Etc/UTC" > /etc/localtime && \ apt update && \ - apt -y dist-upgrade && \ apt -y install wget make gcc g++ python && \ cd ~ && \ wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \ @@ -80,13 +79,12 @@ ARG GID=991 RUN apt update && \ echo "Etc/UTC" > /etc/localtime && \ ln -s /opt/jemalloc/lib/* /usr/lib/ && \ - apt -y dist-upgrade && \ apt install -y whois wget && \ addgroup --gid $GID mastodon && \ useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \ echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd -# Install masto runtime deps +# Install mastodon runtime deps RUN apt -y --no-install-recommends install \ libssl1.1 libpq5 imagemagick ffmpeg \ libicu60 libprotobuf10 libidn11 libyaml-0-2 \ @@ -95,7 +93,7 @@ RUN apt -y --no-install-recommends install \ ln -s /opt/mastodon /mastodon && \ gem install bundler && \ rm -rf /var/cache && \ - rm -rf /var/lib/apt + rm -rf /var/lib/apt/lists/* # Add tini ENV TINI_VERSION="0.18.0" @@ -104,11 +102,11 @@ ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tin RUN echo "$TINI_SUM tini" | sha256sum -c - RUN chmod +x /tini -# Copy over masto source, and dependencies from building, and set permissions +# Copy over mastodon source, and dependencies from building, and set permissions COPY --chown=mastodon:mastodon . /opt/mastodon COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon -# Run masto services in prod mode +# Run mastodon services in prod mode ENV RAILS_ENV="production" ENV NODE_ENV="production" diff --git a/Gemfile b/Gemfile index 242721733..d3b90e3df 100644 --- a/Gemfile +++ b/Gemfile @@ -15,12 +15,13 @@ gem 'makara', '~> 0.4' gem 'pghero', '~> 2.2' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.36', require: false +gem 'aws-sdk-s3', '~> 1.42', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' gem 'paperclip-av-transcoder', '~> 0.6' gem 'streamio-ffmpeg', '~> 3.0' +gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.6' @@ -29,7 +30,7 @@ gem 'browser' gem 'charlock_holmes', '~> 0.7.6' gem 'iso-639' gem 'chewy', '~> 5.0' -gem 'cld3', '~> 3.2.3' +gem 'cld3', '~> 3.2.4' gem 'devise', '~> 4.6' gem 'devise-two-factor', '~> 3.0' @@ -42,7 +43,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' -gem 'doorkeeper', '~> 5.0' +gem 'doorkeeper', '~> 5.1' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'goldfinger', '~> 2.1' @@ -53,7 +54,7 @@ gem 'htmlentities', '~> 4.3' gem 'http', '~> 3.3' gem 'http_accept_language', '~> 2.1' gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2' -gem 'httplog', '~> 1.2' +gem 'httplog', '~> 1.3' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' @@ -62,11 +63,11 @@ gem 'nokogiri', '~> 1.10' gem 'nsa', '~> 0.2' gem 'oj', '~> 3.7' gem 'ostatus2', '~> 2.0' -gem 'ox', '~> 2.10' +gem 'ox', '~> 2.11' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' gem 'pundit', '~> 2.0' gem 'premailer-rails' -gem 'rack-attack', '~> 5.4' +gem 'rack-attack', '~> 6.0' gem 'rack-cors', '~> 1.0', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' @@ -82,9 +83,9 @@ gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 4.1' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'stoplight', '~> 2.1.3' -gem 'strong_migrations', '~> 0.3' +gem 'strong_migrations', '~> 0.4' gem 'tty-command', '~> 0.8', require: false -gem 'tty-prompt', '~> 0.18', require: false +gem 'tty-prompt', '~> 0.19', require: false gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2019' gem 'webpacker', '~> 4.0' @@ -94,9 +95,11 @@ gem 'json-ld', '~> 3.0' gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' +gem 'redcarpet', '~> 3.4' + group :development, :test do gem 'fabrication', '~> 2.20' - gem 'fuubar', '~> 2.3' + gem 'fuubar', '~> 2.4' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-byebug', '~> 3.7' gem 'pry-rails', '~> 0.3' @@ -108,15 +111,15 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.16' + gem 'capybara', '~> 3.24' gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.9' gem 'microformats', '~> 4.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.16', require: false - gem 'webmock', '~> 3.5' - gem 'parallel_tests', '~> 2.28' + gem 'webmock', '~> 3.6' + gem 'parallel_tests', '~> 2.29' end group :development do @@ -124,14 +127,14 @@ group :development do gem 'annotate', '~> 2.7' gem 'better_errors', '~> 2.5' gem 'binding_of_caller', '~> 0.7' - gem 'bullet', '~> 5.9' + gem 'bullet', '~> 6.0' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' - gem 'rubocop', '~> 0.67', require: false + gem 'rubocop', '~> 0.71', require: false + gem 'rubocop-rails', '~> 2.0', require: false gem 'brakeman', '~> 4.5', require: false gem 'bundler-audit', '~> 0.6', require: false - gem 'scss_lint', '~> 0.57', require: false gem 'capistrano', '~> 3.11' gem 'capistrano-rails', '~> 1.4' @@ -143,7 +146,7 @@ group :development do end group :production do - gem 'lograge', '~> 0.10' + gem 'lograge', '~> 0.11' gem 'redis-rails', '~> 5.0' end diff --git a/Gemfile.lock b/Gemfile.lock index c6169b260..057b929bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -66,8 +66,8 @@ GEM public_suffix (>= 2.0.2, < 4.0) airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) - annotate (2.7.4) - activerecord (>= 3.2, < 6.0) + annotate (2.7.5) + activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 13.0) arel (9.0.0) ast (2.4.0) @@ -75,20 +75,20 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-eventstream (1.0.2) - aws-partitions (1.147.0) - aws-sdk-core (3.48.3) + aws-eventstream (1.0.3) + aws-partitions (1.175.0) + aws-sdk-core (3.55.0) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.16.0) - aws-sdk-core (~> 3, >= 3.48.2) + aws-sdk-kms (1.21.0) + aws-sdk-core (~> 3, >= 3.53.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.36.0) - aws-sdk-core (~> 3, >= 3.48.2) + aws-sdk-s3 (1.42.0) + aws-sdk-core (~> 3, >= 3.53.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.0) + aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) bcrypt (3.1.12) @@ -99,12 +99,14 @@ GEM rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) - bootsnap (1.4.3) + blurhash (0.1.3) + ffi (~> 1.10.0) + bootsnap (1.4.4) msgpack (~> 1.0) - brakeman (4.5.0) + brakeman (4.5.1) browser (2.5.3) builder (3.2.3) - bullet (5.9.0) + bullet (6.0.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) @@ -127,13 +129,13 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.16.1) + capybara (3.24.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) rack (>= 1.6.0) rack-test (>= 0.6.3) - regexp_parser (~> 1.2) + regexp_parser (~> 1.5) xpath (~> 3.2) case_transform (0.2) activesupport @@ -143,8 +145,8 @@ GEM elasticsearch (>= 2.0.0) elasticsearch-dsl chunky_png (1.3.10) - cld3 (3.2.3) - ffi (>= 1.1.0, < 1.10.0) + cld3 (3.2.4) + ffi (>= 1.1.0, < 1.11.0) climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) @@ -184,8 +186,8 @@ GEM docile (1.3.0) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.0.2) - railties (>= 4.2) + doorkeeper (5.1.0) + railties (>= 5) dotenv (2.7.2) dotenv-rails (2.7.2) dotenv (= 2.7.2) @@ -205,14 +207,14 @@ GEM et-orbi (1.1.6) tzinfo excon (0.62.0) - fabrication (2.20.1) + fabrication (2.20.2) faker (1.9.3) i18n (>= 0.7) faraday (0.15.0) multipart-post (>= 1.2, < 3) fast_blank (1.0.0) fastimage (2.1.5) - ffi (1.9.25) + ffi (1.10.0) fog-core (2.1.0) builder excon (~> 0.58) @@ -229,7 +231,7 @@ GEM fugit (1.1.6) et-orbi (~> 1.1, >= 1.1.6) raabro (~> 1.1) - fuubar (2.3.2) + fuubar (2.4.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) get_process_mem (0.2.3) @@ -251,7 +253,7 @@ GEM railties (>= 4.0.1) hamster (3.0.0) concurrent-ruby (~> 1.0) - hashdiff (0.3.7) + hashdiff (0.4.0) hashie (3.6.0) heapy (0.1.4) highline (2.0.1) @@ -269,7 +271,7 @@ GEM domain_name (~> 0.5) http-form_data (2.1.1) http_accept_language (2.1.1) - httplog (1.2.2) + httplog (1.3.1) rack (>= 1.0) rainbow (>= 2.0.0) i18n (1.6.0) @@ -320,7 +322,7 @@ GEM letter_opener (~> 1.0) railties (>= 3.2) link_header (0.0.8) - lograge (0.10.0) + lograge (0.11.2) actionpack (>= 4) activesupport (>= 4) railties (>= 4) @@ -348,16 +350,16 @@ GEM mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) - msgpack (1.2.9) + msgpack (1.2.10) multi_json (1.13.1) multipart-post (2.0.0) - necromancer (0.4.0) + necromancer (0.5.0) net-ldap (0.16.1) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (5.0.2) nio4r (2.3.1) - nokogiri (1.10.2) + nokogiri (1.10.3) mini_portile2 (~> 2.4.0) nokogumbo (2.0.0) nokogiri (~> 1.8, >= 1.8.4) @@ -366,7 +368,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.7.11) + oj (3.7.12) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) @@ -382,7 +384,7 @@ GEM addressable (~> 2.5) http (~> 3.0) nokogiri (~> 1.8) - ox (2.10.0) + ox (2.11.0) paperclip (6.0.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -393,15 +395,15 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.17.0) - parallel_tests (2.28.0) + parallel_tests (2.29.0) parallel - parser (2.6.2.0) + parser (2.6.3.0) ast (~> 2.4.0) pastel (0.7.2) equatable (~> 0.5.0) tty-color (~> 0.4.0) pg (1.1.4) - pghero (2.2.0) + pghero (2.2.1) activerecord pkg-config (1.3.7) premailer (1.11.1) @@ -420,14 +422,13 @@ GEM pry (~> 0.10) pry-rails (0.3.9) pry (>= 0.10.4) - psych (3.1.0) - public_suffix (3.0.3) + public_suffix (3.1.0) puma (3.12.1) pundit (2.0.1) activesupport (>= 3.0.0) raabro (1.1.6) rack (2.0.7) - rack-attack (5.4.2) + rack-attack (6.0.0) rack (>= 1.0, < 3) rack-cors (1.0.3) rack-protection (2.0.5) @@ -471,15 +472,13 @@ GEM thor (>= 0.19.0, < 2.0) rainbow (3.0.0) rake (12.3.2) - rb-fsevent (0.10.3) - rb-inotify (0.9.10) - ffi (>= 0.5.0, < 2) rdf (3.0.9) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.3) rdf (>= 2.2, < 4.0) - redis (4.1.0) + redcarpet (3.4.0) + redis (4.1.2) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) @@ -498,7 +497,7 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.5.0) redis (>= 2.2, < 5) - regexp_parser (1.3.0) + regexp_parser (1.5.1) request_store (1.4.1) rack (>= 1.4) responders (2.4.1) @@ -528,33 +527,27 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.8.0) - rubocop (0.67.1) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - psych (>= 3.1.0) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.6) - ruby-progressbar (1.10.0) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) ruby-saml (1.9.0) nokogiri (>= 1.5.10) rufus-scheduler (3.5.2) fugit (~> 1.1, >= 1.1.5) - safe_yaml (1.0.4) + safe_yaml (1.0.5) sanitize (5.0.0) crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) - sass (3.6.0) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - scss_lint (0.57.1) - rake (>= 0.9, < 13) - sass (~> 3.5, >= 3.5.5) - sidekiq (5.2.5) + sidekiq (5.2.7) connection_pool (~> 2.2, >= 2.2.2) rack (>= 1.5.0) rack-protection (>= 1.5.0) @@ -566,7 +559,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.12) + sidekiq-unique-jobs (6.0.13) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) @@ -595,8 +588,8 @@ GEM stoplight (2.1.3) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.3.1) - activerecord (>= 3.2.0) + strong_migrations (0.4.0) + activerecord (>= 5) temple (0.8.1) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) @@ -605,22 +598,19 @@ GEM thor (0.20.3) thread_safe (0.3.6) tilt (2.0.9) - timers (4.2.0) tty-color (0.4.3) tty-command (0.8.2) pastel (~> 0.7.0) - tty-cursor (0.6.0) - tty-prompt (0.18.1) - necromancer (~> 0.4.0) + tty-cursor (0.7.0) + tty-prompt (0.19.0) + necromancer (~> 0.5.0) pastel (~> 0.7.0) - timers (~> 4.0) - tty-cursor (~> 0.6.0) - tty-reader (~> 0.5.0) - tty-reader (0.5.0) - tty-cursor (~> 0.6.0) - tty-screen (~> 0.6.4) + tty-reader (~> 0.6.0) + tty-reader (0.6.0) + tty-cursor (~> 0.7) + tty-screen (~> 0.7) wisper (~> 2.0.0) - tty-screen (0.6.5) + tty-screen (0.7.0) twitter-text (1.14.7) unf (~> 0.1.0) tzinfo (1.2.5) @@ -630,19 +620,19 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.5) - unicode-display_width (1.5.0) + unicode-display_width (1.6.0) uniform_notifier (1.12.1) warden (1.2.8) rack (>= 2.0.6) - webmock (3.5.1) + webmock (3.6.0) addressable (>= 2.3.6) crack (>= 0.3.2) - hashdiff - webpacker (4.0.2) + hashdiff (>= 0.4.0, < 2.0.0) + webpacker (4.0.7) activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) - webpush (0.3.7) + webpush (0.3.8) hkdf (~> 0.2) jwt (~> 2.0) websocket-driver (0.7.0) @@ -660,29 +650,30 @@ DEPENDENCIES active_record_query_trace (~> 1.6) addressable (~> 2.6) annotate (~> 2.7) - aws-sdk-s3 (~> 1.36) + aws-sdk-s3 (~> 1.42) better_errors (~> 2.5) binding_of_caller (~> 0.7) + blurhash (~> 0.1) bootsnap (~> 1.4) brakeman (~> 4.5) browser - bullet (~> 5.9) + bullet (~> 6.0) bundler-audit (~> 0.6) capistrano (~> 3.11) capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.16) + capybara (~> 3.24) charlock_holmes (~> 0.7.6) chewy (~> 5.0) - cld3 (~> 3.2.3) + cld3 (~> 3.2.4) climate_control (~> 0.2) concurrent-ruby derailed_benchmarks devise (~> 4.6) devise-two-factor (~> 3.0) devise_pam_authenticatable2 (~> 9.2) - doorkeeper (~> 5.0) + doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) faker (~> 1.9) @@ -690,7 +681,7 @@ DEPENDENCIES fastimage fog-core (<= 2.1.0) fog-openstack (~> 0.3) - fuubar (~> 2.3) + fuubar (~> 2.4) goldfinger (~> 2.1) hamlit-rails (~> 0.2) hiredis (~> 0.6) @@ -699,7 +690,7 @@ DEPENDENCIES http (~> 3.3) http_accept_language (~> 2.1) http_parser.rb (~> 0.6)! - httplog (~> 1.2) + httplog (~> 1.3) i18n-tasks (~> 0.9) idn-ruby iso-639 @@ -709,7 +700,7 @@ DEPENDENCIES letter_opener (~> 1.7) letter_opener_web (~> 1.3) link_header (~> 0.0) - lograge (~> 0.10) + lograge (~> 0.11) makara (~> 0.4) mario-redis-lock (~> 1.2) memory_profiler @@ -723,10 +714,10 @@ DEPENDENCIES omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) ostatus2 (~> 2.0) - ox (~> 2.10) + ox (~> 2.11) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel_tests (~> 2.28) + parallel_tests (~> 2.29) pg (~> 1.1) pghero (~> 2.2) pkg-config (~> 1.3) @@ -737,22 +728,23 @@ DEPENDENCIES pry-rails (~> 0.3) puma (~> 3.12) pundit (~> 2.0) - rack-attack (~> 5.4) + rack-attack (~> 6.0) rack-cors (~> 1.0) rails (~> 5.2.3) rails-controller-testing (~> 1.0) rails-i18n (~> 5.1) rails-settings-cached (~> 0.6) rdf-normalize (~> 0.3) + redcarpet (~> 3.4) redis (~> 4.1) redis-namespace (~> 1.5) redis-rails (~> 5.0) rqrcode (~> 0.10) rspec-rails (~> 3.8) rspec-sidekiq (~> 3.0) - rubocop (~> 0.67) + rubocop (~> 0.71) + rubocop-rails (~> 2.0) sanitize (~> 5.0) - scss_lint (~> 0.57) sidekiq (~> 5.2) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) @@ -764,13 +756,13 @@ DEPENDENCIES stackprof stoplight (~> 2.1.3) streamio-ffmpeg (~> 3.0) - strong_migrations (~> 0.3) + strong_migrations (~> 0.4) thor (~> 0.20) tty-command (~> 0.8) - tty-prompt (~> 0.18) + tty-prompt (~> 0.19) twitter-text (~> 1.14) tzinfo-data (~> 1.2019) - webmock (~> 3.5) + webmock (~> 3.6) webpacker (~> 4.0) webpush diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index fcdebb47f..051b6ecbd 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -47,8 +47,6 @@ class AccountsController < ApplicationController end format.json do - mark_cacheable! - render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 853f4f907..012c3c538 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -9,8 +9,6 @@ class ActivityPub::CollectionsController < Api::BaseController before_action :set_cache_headers def show - skip_session! - render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do ActiveModelSerializers::SerializableResource.new( collection_presenter, diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 438fa226e..5147afbf7 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -10,10 +10,7 @@ class ActivityPub::OutboxesController < Api::BaseController before_action :set_cache_headers def show - unless page_requested? - skip_session! - expires_in 1.minute, public: true - end + expires_in 1.minute, public: true unless page_requested? render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index e7795e95c..0c7760d77 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -48,13 +48,13 @@ module Admin def approve authorize @account.user, :approve? @account.user.approve! - redirect_to admin_accounts_path(pending: '1') + redirect_to admin_pending_accounts_path end def reject authorize @account.user, :reject? SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) - redirect_to admin_accounts_path(pending: '1') + redirect_to admin_pending_accounts_path end def unsilence @@ -127,6 +127,7 @@ module Admin :by_domain, :active, :pending, + :disabled, :silenced, :suspended, :username, diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index f23ed1508..aedfeb70e 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -30,6 +30,7 @@ module Admin @trending_hashtags = TrendingTags.get(7) @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview + @keybase_integration = Setting.enable_keybase end private diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 5f307ddee..377cac8ad 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -13,13 +13,25 @@ module Admin authorize :domain_block, :create? @domain_block = DomainBlock.new(resource_params) + existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil - if @domain_block.save - DomainBlockWorker.perform_async(@domain_block.id) - log_action :create, @domain_block - redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') - else + if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) + @domain_block.save + flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety + @domain_block.errors[:domain].clear render :new + else + if existing_domain_block.present? + @domain_block = existing_domain_block + @domain_block.update(resource_params) + end + if @domain_block.save + DomainBlockWorker.perform_async(@domain_block.id) + log_action :create, @domain_block + redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + else + render :new + end end end @@ -29,7 +41,7 @@ module Admin def destroy authorize @domain_block, :destroy? - UnblockDomainService.new.call(@domain_block, retroactive_unblock?) + UnblockDomainService.new.call(@domain_block) log_action :destroy, @domain_block redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg') end @@ -41,11 +53,7 @@ module Admin end def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :retroactive) - end - - def retroactive_unblock? - ActiveRecord::Type.lookup(:boolean).cast(resource_params[:retroactive]) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports) end end end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 6dd659a30..7888e844f 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -18,7 +18,7 @@ module Admin @blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count @available = DeliveryFailureTracker.available?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url) @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) - @domain_block = DomainBlock.find_by(domain: params[:id]) + @domain_block = DomainBlock.rule_for(params[:id]) end private diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 3a92ee4e4..eca558f42 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController skip_before_action :store_current_location skip_before_action :check_user_permissions + before_action :set_cache_headers + protect_from_forgery with: :null_session rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| @@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController def authorize_if_got_token!(*scopes) doorkeeper_authorize!(*scopes) if doorkeeper_token end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end end diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb new file mode 100644 index 000000000..29c9b7107 --- /dev/null +++ b/app/controllers/api/v1/admin/account_actions_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Api::V1::Admin::AccountActionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' } + before_action :require_staff! + before_action :set_account + + def create + account_action = Admin::AccountAction.new(resource_params) + account_action.target_account = @account + account_action.current_account = current_account + account_action.save! + + render_empty + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def resource_params + params.permit( + :type, + :report_id, + :warning_preset_id, + :text, + :send_email_notification + ) + end +end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb new file mode 100644 index 000000000..c306180ca --- /dev/null +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class Api::V1::Admin::AccountsController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show] + before_action :require_staff! + before_action :set_accounts, only: :index + before_action :set_account, except: :index + before_action :require_local_account!, only: [:enable, :approve, :reject] + + after_action :insert_pagination_headers, only: :index + + FILTER_PARAMS = %i( + local + remote + by_domain + active + pending + disabled + silenced + suspended + username + display_name + email + ip + staff + ).freeze + + PAGINATION_PARAMS = (%i(limit) + FILTER_PARAMS).freeze + + def index + authorize :account, :index? + render json: @accounts, each_serializer: REST::Admin::AccountSerializer + end + + def show + authorize @account, :show? + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def enable + authorize @account.user, :enable? + @account.user.enable! + log_action :enable, @account.user + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def approve + authorize @account.user, :approve? + @account.user.approve! + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def reject + authorize @account.user, :reject? + SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def unsilence + authorize @account, :unsilence? + @account.unsilence! + log_action :unsilence, @account + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def unsuspend + authorize @account, :unsuspend? + @account.unsuspend! + log_action :unsuspend, @account + render json: @account, serializer: REST::Admin::AccountSerializer + end + + private + + def set_accounts + @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_account + @account = Account.find(params[:id]) + end + + def filtered_accounts + AccountFilter.new(filter_params).results + end + + def filter_params + params.permit(*FILTER_PARAMS) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end + + def require_local_account! + forbidden unless @account.local? && @account.user.present? + end +end diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb new file mode 100644 index 000000000..1d48d3160 --- /dev/null +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class Api::V1::Admin::ReportsController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show] + before_action :require_staff! + before_action :set_reports, only: :index + before_action :set_report, except: :index + + after_action :insert_pagination_headers, only: :index + + FILTER_PARAMS = %i( + resolved + account_id + target_account_id + ).freeze + + PAGINATION_PARAMS = (%i(limit) + FILTER_PARAMS).freeze + + def index + authorize :report, :index? + render json: @reports, each_serializer: REST::Admin::ReportSerializer + end + + def show + authorize @report, :show? + render json: @report, serializer: REST::Admin::ReportSerializer + end + + def assign_to_self + authorize @report, :update? + @report.update!(assigned_account_id: current_account.id) + log_action :assigned_to_self, @report + render json: @report, serializer: REST::Admin::ReportSerializer + end + + def unassign + authorize @report, :update? + @report.update!(assigned_account_id: nil) + log_action :unassigned, @report + render json: @report, serializer: REST::Admin::ReportSerializer + end + + def reopen + authorize @report, :update? + @report.unresolve! + log_action :reopen, @report + render json: @report, serializer: REST::Admin::ReportSerializer + end + + def resolve + authorize @report, :update? + @report.resolve!(current_account) + log_action :resolve, @report + render json: @report, serializer: REST::Admin::ReportSerializer + end + + private + + def set_reports + @reports = filtered_reports.order(id: :desc).with_accounts.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_report + @report = Report.find(params[:id]) + end + + def filtered_reports + ReportFilter.new(filter_params).results + end + + def filter_params + params.permit(*FILTER_PARAMS) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? + end + + def pagination_max_id + @reports.last.id + end + + def pagination_since_id + @reports.first.id + end + + def records_continue? + @reports.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end +end diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 7bac27da4..1bb19a09d 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -3,6 +3,8 @@ class Api::V1::CustomEmojisController < Api::BaseController respond_to :json + skip_before_action :set_cache_headers + def index render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer) diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index e14e0aee8..09edfe365 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Instances::ActivityController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers respond_to :json diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 2070c487d..a8891d126 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Instances::PeersController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers respond_to :json diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 5686e8d7c..8c83a1801 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -2,6 +2,7 @@ class Api::V1::InstancesController < Api::BaseController respond_to :json + skip_before_action :set_cache_headers def show render_cached_json('api:v1:instances', expires_in: 5.minutes) do diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 3b492c516..c91753ae7 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -53,7 +53,7 @@ class Api::V1::NotificationsController < Api::BaseController end def browserable_account_notifications - current_account.notifications.browserable(exclude_types) + current_account.notifications.browserable(exclude_types, from_account) end def target_statuses_from_notifications @@ -90,6 +90,10 @@ class Api::V1::NotificationsController < Api::BaseController val end + def from_account + params[:account_id] + end + def pagination_params(core_params) params.slice(:limit, :exclude_types).permit(:limit, exclude_types: []).merge(core_params) end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index 4f4a6858d..031e6d42d 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -1,13 +1,28 @@ # frozen_string_literal: true class Api::V1::PollsController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show + before_action :set_poll + before_action :refresh_poll respond_to :json def show - @poll = Poll.attached.find(params[:id]) - ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale? render json: @poll, serializer: REST::PollSerializer, include_results: true end + + private + + def set_poll + @poll = Poll.attached.find(params[:id]) + authorize @poll.status, :show? + rescue Mastodon::NotPermittedError + raise ActiveRecord::RecordNotFound + end + + def refresh_poll + ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale? + end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 1a19bd0ef..1b658f870 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention]) + params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index f9506971a..26a0ab457 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -54,6 +54,7 @@ class Api::V1::StatusesController < Api::BaseController scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, poll: status_params[:poll], + content_type: status_params[:content_type], idempotency: request.headers['Idempotency-Key']) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer @@ -65,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController RemovalWorker.perform_async(@status.id) - render_empty + render json: @status, serializer: REST::StatusSerializer, source_requested: true end private @@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :content_type, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index fe8e42580..d8153e082 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -22,6 +22,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController favourite: alerts_enabled, reblog: alerts_enabled, mention: alerts_enabled, + poll: alerts_enabled, }, } @@ -57,6 +58,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def data_params - @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention]) + @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5401b9d59..333082f68 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -228,11 +228,6 @@ class ApplicationController < ActionController::Base end def mark_cacheable! - skip_session! expires_in 0, public: true end - - def skip_session! - request.session_options[:skip] = true - end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 84099bd96..c56728464 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_invite - @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil + invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil + @invite = invite&.valid_for_use? ? invite : nil end def determine_layout diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index 4f28941ae..1c422096c 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -70,7 +70,6 @@ module AccountControllerConcern def check_account_suspension if @account.suspended? - skip_session! expires_in(3.minutes, public: true) gone end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 91566c4fa..90a57197c 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -43,13 +43,7 @@ module SignatureVerification return end - account_stoplight = Stoplight("source:#{request.ip}") { account_from_key_id(signature_params['keyId']) } - .with_fallback { nil } - .with_threshold(1) - .with_cool_off_time(5.minutes.seconds) - .with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) } - - account = account_stoplight.run + account = account_from_key_id(signature_params['keyId']) if account.nil? @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @@ -62,13 +56,7 @@ module SignatureVerification return account unless verify_signature(account, signature, compare_signed_string).nil? - account_stoplight = Stoplight("source:#{request.ip}") { account.possibly_stale? ? account.refresh! : account_refresh_key(account) } - .with_fallback { nil } - .with_threshold(1) - .with_cool_off_time(5.minutes.seconds) - .with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) } - - account = account_stoplight.run + account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) } if account.nil? @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @@ -136,14 +124,23 @@ module SignatureVerification def account_from_key_id(key_id) if key_id.start_with?('acct:') - ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) + stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) - account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) + account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) } account end end + def stoplight_wrap_request(&block) + Stoplight("source:#{request.remote_ip}", &block) + .with_fallback { nil } + .with_threshold(1) + .with_cool_off_time(5.minutes.seconds) + .with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) } + .run + end + def account_refresh_key(account) return if account.local? || !account.activitypub? ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true) diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 31e501609..6e80feaf8 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class CustomCssController < ApplicationController + skip_before_action :store_current_location + before_action :set_cache_headers def show - skip_session! render plain: Setting.custom_css || '', content_type: 'text/css' end end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb index 5d306e600..3feb08132 100644 --- a/app/controllers/emojis_controller.rb +++ b/app/controllers/emojis_controller.rb @@ -7,8 +7,6 @@ class EmojisController < ApplicationController def show respond_to do |format| format.json do - skip_session! - render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter) end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 1462b94fc..fab9c8462 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -20,10 +20,7 @@ class FollowerAccountsController < ApplicationController format.json do raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? - if params[:page].blank? - skip_session! - expires_in 3.minutes, public: true - end + expires_in 3.minutes, public: true if params[:page].blank? render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 181f85221..272116040 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -20,10 +20,7 @@ class FollowingAccountsController < ApplicationController format.json do raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? - if params[:page].blank? - skip_session! - expires_in 3.minutes, public: true - end + expires_in 3.minutes, public: true if params[:page].blank? render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 06ca03e34..17cf9e07b 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -64,7 +64,7 @@ class HomeController < ApplicationController if request.path.start_with?('/web') new_user_session_path elsif single_user_mode? - short_account_path(Account.local.where(suspended: false).first) + short_account_path(Account.local.without_suspended.first) else about_path end diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index ac267c229..332d845d8 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ManifestsController < ApplicationController + skip_before_action :store_current_location + def show render json: InstancePresenter.new, serializer: ManifestSerializer end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 8e1624ce1..d44b52d26 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -3,8 +3,12 @@ class MediaController < ApplicationController include Authorization + skip_before_action :store_current_location + before_action :set_media_attachment before_action :verify_permitted_status! + before_action :check_playable, only: :player + before_action :allow_iframing, only: :player content_security_policy only: :player do |p| p.frame_ancestors(false) @@ -16,8 +20,6 @@ class MediaController < ApplicationController def player @body_classes = 'player' - response.headers['X-Frame-Options'] = 'ALLOWALL' - raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv? end private @@ -32,4 +34,12 @@ class MediaController < ApplicationController # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound end + + def check_playable + not_found unless @media_attachment.larger_media_format? + end + + def allow_iframing + response.headers['X-Frame-Options'] = 'ALLOWALL' + end end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d820b257e..8fc18dd06 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -3,6 +3,8 @@ class MediaProxyController < ApplicationController include RoutingHelper + skip_before_action :store_current_location + def show RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -37,6 +39,6 @@ class MediaProxyController < ApplicationController end def reject_media? - DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media? + DomainBlock.reject_media?(@media_attachment.account.domain) end end diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb index e22b4d9be..e84c1aca6 100644 --- a/app/controllers/settings/identity_proofs_controller.rb +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -5,6 +5,7 @@ class Settings::IdentityProofsController < Settings::BaseController before_action :authenticate_user! before_action :check_required_params, only: :new + before_action :check_enabled, only: :new def index @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc) @@ -41,6 +42,10 @@ class Settings::IdentityProofsController < Settings::BaseController private + def check_enabled + not_found unless Setting.enable_keybase + end + def check_required_params redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? } end @@ -56,8 +61,4 @@ class Settings::IdentityProofsController < Settings::BaseController def post_params params.require(:account_identity_proof).permit(:post_status, :status_text) end - - def set_body_classes - @body_classes = '' - end end diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb deleted file mode 100644 index 68ebddfc9..000000000 --- a/app/controllers/settings/notifications_controller.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class Settings::NotificationsController < Settings::BaseController - def show; end - - def update - user_settings.update(user_settings_params.to_h) - - if current_user.save - redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg') - else - render :show - end - end - - private - - def user_settings - UserSettingsDecorator.new(current_user) - end - - def user_settings_params - params.require(:user).permit( - notification_emails: %i(follow follow_request reblog favourite mention digest report), - interactions: %i(must_be_follower must_be_following must_be_following_dm) - ) - end -end diff --git a/app/controllers/settings/preferences/appearance_controller.rb b/app/controllers/settings/preferences/appearance_controller.rb new file mode 100644 index 000000000..80ea57bd2 --- /dev/null +++ b/app/controllers/settings/preferences/appearance_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::AppearanceController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_appearance_path + end +end diff --git a/app/controllers/settings/preferences/notifications_controller.rb b/app/controllers/settings/preferences/notifications_controller.rb new file mode 100644 index 000000000..a16ae6a67 --- /dev/null +++ b/app/controllers/settings/preferences/notifications_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::NotificationsController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_notifications_path + end +end diff --git a/app/controllers/settings/preferences/other_controller.rb b/app/controllers/settings/preferences/other_controller.rb new file mode 100644 index 000000000..07eb89a76 --- /dev/null +++ b/app/controllers/settings/preferences/other_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::OtherController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_other_path + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index eb7a0eb4a..5103cc50e 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -8,7 +8,7 @@ class Settings::PreferencesController < Settings::BaseController if current_user.update(user_params) I18n.locale = current_user.locale - redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg') else render :show end @@ -16,6 +16,10 @@ class Settings::PreferencesController < Settings::BaseController private + def after_update_redirect_path + settings_preferences_path + end + def user_settings UserSettingsDecorator.new(current_user) end @@ -46,8 +50,10 @@ class Settings::PreferencesController < Settings::BaseController :setting_hide_followers_count, :setting_aggregate_reblogs, :setting_show_application, + :setting_advanced_layout, + :setting_default_content_type, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), - interactions: %i(must_be_follower must_be_following) + interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 53cf1c4ca..66ba260aa 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -28,7 +28,8 @@ class StatusesController < ApplicationController respond_to do |format| format.html do use_pack 'public' - mark_cacheable! unless user_signed_in? + + expires_in 10.seconds, public: true if current_account.nil? @body_classes = 'with-modals' @@ -39,8 +40,6 @@ class StatusesController < ApplicationController end format.json do - mark_cacheable! unless @stream_entry.hidden? - render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) end @@ -49,8 +48,6 @@ class StatusesController < ApplicationController end def activity - skip_session! - render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter) end @@ -60,7 +57,6 @@ class StatusesController < ApplicationController use_pack 'embed' raise ActiveRecord::RecordNotFound if @status.hidden? - skip_session! expires_in 180, public: true response.headers['X-Frame-Options'] = 'ALLOWALL' @autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay]) @@ -69,8 +65,6 @@ class StatusesController < ApplicationController end def replies - skip_session! - render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 8cb54a148..1ee85592c 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -16,14 +16,14 @@ class StreamEntriesController < ApplicationController respond_to do |format| format.html do use_pack 'public' - redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status' + + expires_in 5.minutes, public: true unless @stream_entry.hidden? + + redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) end format.atom do - unless @stream_entry.hidden? - skip_session! - expires_in 3.minutes, public: true - end + expires_in 3.minutes, public: true unless @stream_entry.hidden? render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) end @@ -51,7 +51,7 @@ class StreamEntriesController < ApplicationController def set_stream_entry @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) - @type = @stream_entry.activity_type.downcase + @type = 'status' raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only? diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb index eb41e586f..c78683a8d 100644 --- a/app/controllers/well_known/keybase_proof_config_controller.rb +++ b/app/controllers/well_known/keybase_proof_config_controller.rb @@ -2,8 +2,16 @@ module WellKnown class KeybaseProofConfigController < ActionController::Base + before_action :check_enabled + def show render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer end + + private + + def check_enabled + head 404 unless Setting.enable_keybase + end end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 6e646ab84..6a71f1c02 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -16,24 +16,32 @@ module StreamEntriesHelper if user_signed_in? if account.id == current_user.account_id link_to settings_profile_url, class: 'button logo-button' do - safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('settings.edit_profile')]) + safe_join([svg_logo, 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([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')]) + safe_join([svg_logo, 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([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')]) + safe_join([svg_logo, 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([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')]) + safe_join([svg_logo, t('accounts.follow')]) end end end + def svg_logo + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') + end + + def svg_logo_full + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678') + end + def account_badge(account, all: false) if account.bot? content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js index f00709dad..33b7a207d 100644 --- a/app/javascript/core/public.js +++ b/app/javascript/core/public.js @@ -14,15 +14,15 @@ delegate(document, '.webapp-btn', 'click', ({ target, button }) => { return false; }); -delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => { - const contentEl = target.parentNode.parentNode.querySelector('.e-content'); +delegate(document, '.status__content__spoiler-link', 'click', function() { + const contentEl = this.parentNode.parentNode.querySelector('.e-content'); if (contentEl.style.display === 'block') { contentEl.style.display = 'none'; - target.parentNode.style.marginBottom = 0; + this.parentNode.style.marginBottom = 0; } else { contentEl.style.display = 'block'; - target.parentNode.style.marginBottom = null; + this.parentNode.style.marginBottom = null; } return false; @@ -41,3 +41,26 @@ delegate(document, '.modal-button', 'click', e => { window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); }); + +const getProfileAvatarAnimationHandler = (swapTo) => { + //animate avatar gifs on the profile page when moused over + return ({ target }) => { + const swapSrc = target.getAttribute(swapTo); + //only change the img source if autoplay is off and the image src is actually different + if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) { + target.src = swapSrc; + } + }; +}; + +delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); + +delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml index 32787bcab..dc641772c 100644 --- a/app/javascript/core/theme.yml +++ b/app/javascript/core/theme.yml @@ -3,7 +3,7 @@ pack: about: admin: admin.js - auth: + auth: settings.js common: filename: common.js stylesheet: true diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js index b2c7ab76a..ef2500e7b 100644 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -8,6 +8,7 @@ const messages = defineMessages({ export const ALERT_SHOW = 'ALERT_SHOW'; export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_CLEAR = 'ALERT_CLEAR'; +export const ALERT_NOOP = 'ALERT_NOOP'; export function dismissAlert(alert) { return { @@ -36,7 +37,7 @@ export function showAlertForError(error) { if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI - return {}; + return { type: ALERT_NOOP }; } let message = statusText; diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index ac09adceb..69cc6827f 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -46,6 +46,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; @@ -67,6 +68,14 @@ const messages = defineMessages({ 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) => { + if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { + routerHistory.push('/statuses/new'); + } +}; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -80,16 +89,14 @@ export function cycleElefriendCompose() { }; }; -export function replyCompose(status, router) { +export function replyCompose(status, routerHistory) { return (dispatch, getState) => { dispatch({ type: COMPOSE_REPLY, status: status, }); - if (router && !getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; @@ -105,29 +112,25 @@ export function resetCompose() { }; }; -export function mentionCompose(account, router) { +export function mentionCompose(account, routerHistory) { return (dispatch, getState) => { dispatch({ type: COMPOSE_MENTION, account: account, }); - if (!getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; -export function directCompose(account, router) { +export function directCompose(account, routerHistory) { return (dispatch, getState) => { dispatch({ type: COMPOSE_DIRECT, account: account, }); - if (!getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; @@ -147,6 +150,7 @@ export function submitCompose(routerHistory) { } api(getState).post('/api/v1/statuses', { status, + content_type: getState().getIn(['compose', 'content_type']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), @@ -227,8 +231,8 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); - const total = Array.from(files).reduce((a, v) => a + v.size, 0); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); @@ -248,6 +252,8 @@ export function uploadCompose(files) { resizeImage(f).then(file => { const data = new FormData(); data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; return api(getState).post('/api/v1/media', data, { onUploadProgress: function({ loaded }){ @@ -405,7 +411,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) { }; }; -export function selectComposeSuggestion(position, token, suggestion) { +export function selectComposeSuggestion(position, token, suggestion, path) { return (dispatch, getState) => { let completion; if (typeof suggestion === 'object' && suggestion.id) { @@ -422,6 +428,7 @@ export function selectComposeSuggestion(position, token, suggestion) { position, token, completion, + path, }); }; }; @@ -514,6 +521,13 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeContentType(value) { + return { + type: COMPOSE_CONTENT_TYPE_CHANGE, + value, + }; +}; + export function insertEmojiCompose(position, emoji) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js new file mode 100644 index 000000000..856f8f10f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/conversations.js @@ -0,0 +1,84 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer'; + +export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; +export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; + +export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; + +export const mountConversations = () => ({ + type: CONVERSATIONS_MOUNT, +}); + +export const unmountConversations = () => ({ + type: CONVERSATIONS_UNMOUNT, +}); + +export const markConversationRead = conversationId => (dispatch, getState) => { + dispatch({ + type: CONVERSATIONS_READ, + id: conversationId, + }); + + api(getState).post(`/api/v1/conversations/${conversationId}/read`); +}; + +export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { + dispatch(expandConversationsRequest()); + + const params = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); + } + + const isLoadingRecent = !!params.since_id; + + api(getState).get('/api/v1/conversations', { params }) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +export const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, + isLoadingRecent, +}); + +export const expandConversationsFail = error => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +export const updateConversations = conversation => dispatch => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 57fecf63d..2107503db 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -62,9 +62,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) { let filtered = false; if (notification.type === 'mention') { + const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); const regex = regexFromFilters(filters); const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + if (dropRegex && dropRegex.test(searchIndex)) { + return; + } + filtered = regex && regex.test(searchIndex); } diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 4eabc4be0..4d2bda78b 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -2,6 +2,7 @@ import api from 'flavours/glitch/util/api'; import { deleteFromTimelines } from './timelines'; import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { ensureComposeIsVisible } from './compose'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -71,14 +72,16 @@ export function fetchStatusFail(id, error, skipLoading) { }; }; -export function redraft(status) { +export function redraft(status, raw_text, content_type) { return { type: REDRAFT, status, + raw_text, + content_type, }; }; -export function deleteStatus(id, router, withRedraft = false) { +export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); @@ -88,16 +91,14 @@ export function deleteStatus(id, router, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api(getState).delete(`/api/v1/statuses/${id}`).then(() => { + api(getState).delete(`/api/v1/statuses/${id}`).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); if (withRedraft) { - dispatch(redraft(status)); + dispatch(redraft(status, response.data.text, response.data.content_type)); - if (!getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); } }).catch(error => { dispatch(deleteStatusFail(id, error)); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index b5dd70989..21379f492 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -7,6 +7,7 @@ import { disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; +import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { getLocale } from 'mastodon/locales'; @@ -37,6 +38,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; case 'filters_changed': dispatch(fetchFilters()); break; diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js new file mode 100644 index 000000000..3687136ff --- /dev/null +++ b/app/javascript/flavours/glitch/actions/suggestions.js @@ -0,0 +1,52 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; + +export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; +export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; +export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; + +export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; + +export function fetchSuggestions() { + return (dispatch, getState) => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v1/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchSuggestionsSuccess(response.data)); + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }; +}; + +export function fetchSuggestionsRequest() { + return { + type: SUGGESTIONS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchSuggestionsSuccess(accounts) { + return { + type: SUGGESTIONS_FETCH_SUCCESS, + accounts, + skipLoading: true, + }; +}; + +export function fetchSuggestionsFail(error) { + return { + type: SUGGESTIONS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +}; + +export const dismissSuggestion = accountId => (dispatch, getState) => { + dispatch({ + type: SUGGESTIONS_DISMISS, + id: accountId, + }); + + api(getState).delete(`/api/v1/suggestions/${accountId}`); +}; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index f218ee06b..cca571583 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -97,7 +97,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, 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 expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +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 expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index 4fcafc509..3fc18cb72 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -31,6 +31,9 @@ export default class Account extends ImmutablePureComponent { intl: PropTypes.object.isRequired, hidden: PropTypes.bool, small: PropTypes.bool, + actionIcon: PropTypes.string, + actionTitle: PropTypes.string, + onActionClick: PropTypes.func, }; handleFollow = () => { @@ -53,12 +56,19 @@ export default class Account extends ImmutablePureComponent { this.props.onMuteNotifications(this.props.account, false); } + handleAction = () => { + this.props.onActionClick(this.props.account); + } + render () { const { account, hidden, intl, small, + onActionClick, + actionIcon, + actionTitle, } = this.props; if (!account) { @@ -76,7 +86,9 @@ export default class Account extends ImmutablePureComponent { let buttons; - if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { + if (onActionClick && actionIcon) { + buttons = ; + } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); const blocking = account.getIn(['relationship', 'blocking']); diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js new file mode 100644 index 000000000..c8609e48f --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = `${assetHost}/emoji/${mapping.filename}.svg`; + } + + return ( +
+ {emoji.native + + {emoji.colons} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js new file mode 100644 index 000000000..5fc952d8e --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -0,0 +1,229 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestInput extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + autoFocus: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + searchTokens: PropTypes.arrayOf(PropTypes.string), + maxLength: PropTypes.number, + }; + + static defaultProps = { + autoFocus: true, + searchTokens: ImmutableList(['@', ':', '#']), + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c) => { + this.input = c; + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( +
+ + +
+ {suggestions.map(this.renderSuggestion)} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js new file mode 100644 index 000000000..bbe0ffcbe --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -0,0 +1,238 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestTextarea extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func.isRequired, + autoFocus: PropTypes.bool, + }; + + static defaultProps = { + autoFocus: true, + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = (e) => { + this.setState({ focused: true }); + if (this.props.onFocus) { + this.props.onFocus(e); + } + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setTextarea = (c) => { + this.textarea = c; + } + + onPaste = (e) => { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files); + e.preventDefault(); + } + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return [ +
+
+