Merge remote-tracking branch 'glitchsoc/master'
commit
bd942ac2a5
|
@ -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:
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
version: 1
|
||||
|
||||
update_configs:
|
||||
- package_manager: "ruby:bundler"
|
||||
directory: "/"
|
||||
update_schedule: "weekly"
|
||||
|
||||
- package_manager: "javascript"
|
||||
directory: "/"
|
||||
update_schedule: "weekly"
|
|
@ -160,6 +160,21 @@ STREAMING_CLUSTER_NUM=1
|
|||
# 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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
patreon: mastodon
|
||||
open_collective: mastodon
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
264
.scss-lint.yml
264
.scss-lint.yml
|
@ -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
|
|
@ -43,4 +43,4 @@ Gruntfile.js
|
|||
|
||||
# for specific ignore
|
||||
!.svgo.yml
|
||||
|
||||
!sass-lint/**/*.yml
|
||||
|
|
126
CHANGELOG.md
126
CHANGELOG.md
|
@ -3,6 +3,132 @@ 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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -52,7 +52,9 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://
|
|||
|
||||
## Translations
|
||||
|
||||
You can submit translations via pull request.
|
||||
You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase.
|
||||
|
||||
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
|
||||
|
||||
## Pull requests
|
||||
|
||||
|
|
10
Dockerfile
10
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"
|
||||
|
||||
|
|
23
Gemfile
23
Gemfile
|
@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
|
|||
gem 'pghero', '~> 2.2'
|
||||
gem 'dotenv-rails', '~> 2.7'
|
||||
|
||||
gem 'aws-sdk-s3', '~> 1.39', require: false
|
||||
gem 'aws-sdk-s3', '~> 1.45', require: false
|
||||
gem 'fog-core', '<= 2.1.0'
|
||||
gem 'fog-openstack', '~> 0.3', require: false
|
||||
gem 'paperclip', '~> 6.0'
|
||||
|
@ -54,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'
|
||||
|
@ -63,7 +63,7 @@ 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'
|
||||
|
@ -83,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'
|
||||
|
@ -99,7 +99,7 @@ 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'
|
||||
|
@ -111,14 +111,14 @@ group :production, :test do
|
|||
end
|
||||
|
||||
group :test do
|
||||
gem 'capybara', '~> 3.20'
|
||||
gem 'capybara', '~> 3.25'
|
||||
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 'simplecov', '~> 0.17', require: false
|
||||
gem 'webmock', '~> 3.6'
|
||||
gem 'parallel_tests', '~> 2.29'
|
||||
end
|
||||
|
||||
|
@ -131,10 +131,10 @@ group :development do
|
|||
gem 'letter_opener', '~> 1.7'
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', '~> 0.69', require: false
|
||||
gem 'rubocop', '~> 0.72', require: false
|
||||
gem 'rubocop-rails', '~> 2.2', require: false
|
||||
gem 'brakeman', '~> 4.5', require: false
|
||||
gem 'bundler-audit', '~> 0.6', require: false
|
||||
gem 'scss_lint', '~> 0.58', require: false
|
||||
|
||||
gem 'capistrano', '~> 3.11'
|
||||
gem 'capistrano-rails', '~> 1.4'
|
||||
|
@ -151,3 +151,4 @@ group :production do
|
|||
end
|
||||
|
||||
gem 'concurrent-ruby', require: false
|
||||
gem 'connection_pool', require: false
|
||||
|
|
140
Gemfile.lock
140
Gemfile.lock
|
@ -76,19 +76,19 @@ GEM
|
|||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-eventstream (1.0.3)
|
||||
aws-partitions (1.162.0)
|
||||
aws-sdk-core (3.52.1)
|
||||
aws-partitions (1.184.0)
|
||||
aws-sdk-core (3.59.0)
|
||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||
aws-partitions (~> 1.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.20.0)
|
||||
aws-sdk-core (~> 3, >= 3.52.1)
|
||||
aws-sdk-kms (1.23.0)
|
||||
aws-sdk-core (~> 3, >= 3.58.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.39.0)
|
||||
aws-sdk-core (~> 3, >= 3.52.1)
|
||||
aws-sdk-s3 (1.45.0)
|
||||
aws-sdk-core (~> 3, >= 3.58.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)
|
||||
|
@ -106,7 +106,7 @@ GEM
|
|||
brakeman (4.5.1)
|
||||
browser (2.5.3)
|
||||
builder (3.2.3)
|
||||
bullet (6.0.0)
|
||||
bullet (6.0.1)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.6.1)
|
||||
|
@ -129,14 +129,13 @@ GEM
|
|||
sshkit (~> 1.3)
|
||||
capistrano-yarn (2.0.2)
|
||||
capistrano (~> 3.0)
|
||||
capybara (3.20.0)
|
||||
capybara (3.25.0)
|
||||
addressable
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.8)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (~> 1.2)
|
||||
uglifier
|
||||
regexp_parser (~> 1.5)
|
||||
xpath (~> 3.2)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
|
@ -160,7 +159,7 @@ GEM
|
|||
css_parser (1.6.0)
|
||||
addressable
|
||||
debug_inspector (0.0.3)
|
||||
derailed_benchmarks (1.3.5)
|
||||
derailed_benchmarks (1.3.6)
|
||||
benchmark-ips (~> 2)
|
||||
get_process_mem (~> 0)
|
||||
heapy (~> 0)
|
||||
|
@ -184,14 +183,14 @@ GEM
|
|||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.3)
|
||||
docile (1.3.0)
|
||||
docile (1.3.2)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (5.1.0)
|
||||
railties (>= 5)
|
||||
dotenv (2.7.2)
|
||||
dotenv-rails (2.7.2)
|
||||
dotenv (= 2.7.2)
|
||||
dotenv (2.7.4)
|
||||
dotenv-rails (2.7.4)
|
||||
dotenv (= 2.7.4)
|
||||
railties (>= 3.2, < 6.1)
|
||||
elasticsearch (6.0.2)
|
||||
elasticsearch-api (= 6.0.2)
|
||||
|
@ -208,9 +207,8 @@ GEM
|
|||
et-orbi (1.1.6)
|
||||
tzinfo
|
||||
excon (0.62.0)
|
||||
execjs (2.7.0)
|
||||
fabrication (2.20.2)
|
||||
faker (1.9.3)
|
||||
faker (1.9.6)
|
||||
i18n (>= 0.7)
|
||||
faraday (0.15.0)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
|
@ -233,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.1)
|
||||
rspec-core (~> 3.0)
|
||||
ruby-progressbar (~> 1.4)
|
||||
get_process_mem (0.2.3)
|
||||
|
@ -255,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)
|
||||
|
@ -273,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)
|
||||
|
@ -291,9 +289,9 @@ GEM
|
|||
idn-ruby (0.1.0)
|
||||
ipaddress (0.8.3)
|
||||
iso-639 (0.2.8)
|
||||
jaro_winkler (1.5.2)
|
||||
jaro_winkler (1.5.3)
|
||||
jmespath (1.4.0)
|
||||
json (2.1.0)
|
||||
json (2.2.0)
|
||||
json-ld (3.0.2)
|
||||
multi_json (~> 1.12)
|
||||
rdf (>= 2.2.8, < 4.0)
|
||||
|
@ -324,7 +322,7 @@ GEM
|
|||
letter_opener (~> 1.0)
|
||||
railties (>= 3.2)
|
||||
link_header (0.0.8)
|
||||
lograge (0.11.0)
|
||||
lograge (0.11.2)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
|
@ -340,7 +338,7 @@ GEM
|
|||
mimemagic (~> 0.3.2)
|
||||
mario-redis-lock (1.2.1)
|
||||
redis (>= 3.0.5)
|
||||
memory_profiler (0.9.13)
|
||||
memory_profiler (0.9.14)
|
||||
method_source (0.9.2)
|
||||
microformats (4.1.0)
|
||||
json (~> 2.1)
|
||||
|
@ -355,7 +353,7 @@ GEM
|
|||
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)
|
||||
|
@ -386,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)
|
||||
|
@ -397,7 +395,7 @@ GEM
|
|||
av (~> 0.9.0)
|
||||
paperclip (>= 2.5.2)
|
||||
parallel (1.17.0)
|
||||
parallel_tests (2.29.0)
|
||||
parallel_tests (2.29.1)
|
||||
parallel
|
||||
parser (2.6.3.0)
|
||||
ast (~> 2.4.0)
|
||||
|
@ -405,7 +403,7 @@ GEM
|
|||
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)
|
||||
|
@ -424,7 +422,7 @@ GEM
|
|||
pry (~> 0.10)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (3.0.3)
|
||||
public_suffix (3.1.1)
|
||||
puma (3.12.1)
|
||||
pundit (2.0.1)
|
||||
activesupport (>= 3.0.0)
|
||||
|
@ -474,16 +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.10.0)
|
||||
ffi (~> 1.0)
|
||||
rdf (3.0.9)
|
||||
hamster (~> 3.0)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.3.3)
|
||||
rdf (>= 2.2, < 4.0)
|
||||
redcarpet (3.4.0)
|
||||
redis (4.1.1)
|
||||
redis (4.1.2)
|
||||
redis-actionpack (5.0.2)
|
||||
actionpack (>= 4.0, < 6)
|
||||
redis-rack (>= 1, < 3)
|
||||
|
@ -502,7 +497,7 @@ GEM
|
|||
redis-store (>= 1.2, < 2)
|
||||
redis-store (1.5.0)
|
||||
redis (>= 2.2, < 5)
|
||||
regexp_parser (1.5.0)
|
||||
regexp_parser (1.5.1)
|
||||
request_store (1.4.1)
|
||||
rack (>= 1.4)
|
||||
responders (2.4.1)
|
||||
|
@ -532,31 +527,26 @@ GEM
|
|||
rspec-core (~> 3.0, >= 3.0.0)
|
||||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.8.0)
|
||||
rubocop (0.69.0)
|
||||
rubocop (0.72.0)
|
||||
jaro_winkler (~> 1.5.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.6)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.7)
|
||||
ruby-progressbar (1.10.0)
|
||||
rubocop-rails (2.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.72.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.7.4)
|
||||
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.58.0)
|
||||
rake (>= 0.9, < 13)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
sidekiq (5.2.7)
|
||||
connection_pool (~> 2.2, >= 2.2.2)
|
||||
rack (>= 1.5.0)
|
||||
|
@ -578,7 +568,7 @@ GEM
|
|||
simple_form (4.1.0)
|
||||
actionpack (>= 5.0)
|
||||
activemodel (>= 5.0)
|
||||
simplecov (0.16.1)
|
||||
simplecov (0.17.0)
|
||||
docile (~> 1.1)
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
|
@ -598,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)
|
||||
|
@ -608,30 +598,25 @@ 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)
|
||||
thread_safe (~> 0.1)
|
||||
tzinfo-data (1.2019.1)
|
||||
tzinfo-data (1.2019.2)
|
||||
tzinfo (>= 1.0.0)
|
||||
uglifier (4.1.20)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.5)
|
||||
|
@ -639,11 +624,11 @@ GEM
|
|||
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)
|
||||
|
@ -665,7 +650,7 @@ DEPENDENCIES
|
|||
active_record_query_trace (~> 1.6)
|
||||
addressable (~> 2.6)
|
||||
annotate (~> 2.7)
|
||||
aws-sdk-s3 (~> 1.39)
|
||||
aws-sdk-s3 (~> 1.45)
|
||||
better_errors (~> 2.5)
|
||||
binding_of_caller (~> 0.7)
|
||||
blurhash (~> 0.1)
|
||||
|
@ -678,12 +663,13 @@ DEPENDENCIES
|
|||
capistrano-rails (~> 1.4)
|
||||
capistrano-rbenv (~> 2.1)
|
||||
capistrano-yarn (~> 2.0)
|
||||
capybara (~> 3.20)
|
||||
capybara (~> 3.25)
|
||||
charlock_holmes (~> 0.7.6)
|
||||
chewy (~> 5.0)
|
||||
cld3 (~> 3.2.4)
|
||||
climate_control (~> 0.2)
|
||||
concurrent-ruby
|
||||
connection_pool
|
||||
derailed_benchmarks
|
||||
devise (~> 4.6)
|
||||
devise-two-factor (~> 3.0)
|
||||
|
@ -696,7 +682,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)
|
||||
|
@ -705,7 +691,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
|
||||
|
@ -729,7 +715,7 @@ 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.29)
|
||||
|
@ -757,27 +743,27 @@ DEPENDENCIES
|
|||
rqrcode (~> 0.10)
|
||||
rspec-rails (~> 3.8)
|
||||
rspec-sidekiq (~> 3.0)
|
||||
rubocop (~> 0.69)
|
||||
rubocop (~> 0.72)
|
||||
rubocop-rails (~> 2.2)
|
||||
sanitize (~> 5.0)
|
||||
scss_lint (~> 0.58)
|
||||
sidekiq (~> 5.2)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 3.0)
|
||||
sidekiq-unique-jobs (~> 6.0)
|
||||
simple-navigation (~> 4.0)
|
||||
simple_form (~> 4.1)
|
||||
simplecov (~> 0.16)
|
||||
simplecov (~> 0.17)
|
||||
sprockets-rails (~> 3.2)
|
||||
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
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index
|
|||
field :id, type: 'long'
|
||||
field :account_id, type: 'long'
|
||||
|
||||
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do
|
||||
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
|
||||
field :stemmed, type: 'text', analyzer: 'content'
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -13,7 +13,7 @@ module Admin
|
|||
authorize :domain_block, :create?
|
||||
|
||||
@domain_block = DomainBlock.new(resource_params)
|
||||
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
|
||||
existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil
|
||||
|
||||
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
|
||||
@domain_block.save
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -7,7 +7,7 @@ class Api::V1::CustomEmojisController < Api::BaseController
|
|||
|
||||
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)
|
||||
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false).includes(:category), each_serializer: REST::CustomEmojiSerializer)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,8 +5,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy]
|
||||
before_action :require_user!, except: [:show, :context, :card]
|
||||
before_action :set_status, only: [:show, :context, :card]
|
||||
before_action :require_user!, except: [:show, :context]
|
||||
before_action :set_status, only: [:show, :context]
|
||||
|
||||
respond_to :json
|
||||
|
||||
|
@ -33,16 +33,6 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
def card
|
||||
@card = @status.preview_cards.first
|
||||
|
||||
if @card.nil?
|
||||
render_empty
|
||||
else
|
||||
render json: @card, serializer: REST::PreviewCardSerializer
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@status = PostStatusService.new.call(current_user.account,
|
||||
text: status_params[:status],
|
||||
|
|
|
@ -27,16 +27,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
|
|||
end
|
||||
|
||||
def direct_timeline_statuses
|
||||
# this query requires built in pagination.
|
||||
Status.as_direct_timeline(
|
||||
current_account,
|
||||
account_direct_feed.get(
|
||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id],
|
||||
true # returns array of cache_ids object
|
||||
params[:min_id]
|
||||
)
|
||||
end
|
||||
|
||||
def account_direct_feed
|
||||
DirectFeed.new(current_account)
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -161,11 +161,15 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def current_account
|
||||
@current_account ||= current_user.try(:account)
|
||||
return @current_account if defined?(@current_account)
|
||||
|
||||
@current_account = current_user&.account
|
||||
end
|
||||
|
||||
def current_session
|
||||
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
|
||||
return @current_session if defined?(@current_session)
|
||||
|
||||
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
|
||||
end
|
||||
|
||||
def current_flavour
|
||||
|
@ -228,11 +232,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
|
||||
|
|
|
@ -70,7 +70,6 @@ module AccountControllerConcern
|
|||
|
||||
def check_account_suspension
|
||||
if @account.suspended?
|
||||
skip_session!
|
||||
expires_in(3.minutes, public: true)
|
||||
gone
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -61,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
|
||||
|
|
|
@ -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 pending_account),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
|
||||
)
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,7 +50,10 @@ class Settings::PreferencesController < Settings::BaseController
|
|||
:setting_hide_followers_count,
|
||||
:setting_aggregate_reblogs,
|
||||
:setting_show_application,
|
||||
:setting_advanced_layout,
|
||||
:setting_default_content_type,
|
||||
:setting_use_blurhash,
|
||||
:setting_use_pending_items,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
|
||||
)
|
||||
|
|
|
@ -29,10 +29,7 @@ class StatusesController < ApplicationController
|
|||
format.html do
|
||||
use_pack 'public'
|
||||
|
||||
unless user_signed_in?
|
||||
skip_session!
|
||||
expires_in 10.seconds, public: true
|
||||
end
|
||||
expires_in 10.seconds, public: true if current_account.nil?
|
||||
|
||||
@body_classes = 'with-modals'
|
||||
|
||||
|
@ -43,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
|
||||
|
@ -53,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
|
||||
|
@ -64,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])
|
||||
|
@ -73,8 +65,6 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
def replies
|
||||
skip_session!
|
||||
|
||||
render json: replies_collection_presenter,
|
||||
serializer: ActivityPub::CollectionSerializer,
|
||||
adapter: ActivityPub::Adapter,
|
||||
|
|
|
@ -17,19 +17,13 @@ class StreamEntriesController < ApplicationController
|
|||
format.html do
|
||||
use_pack 'public'
|
||||
|
||||
unless user_signed_in?
|
||||
skip_session!
|
||||
expires_in 5.minutes, public: true
|
||||
end
|
||||
expires_in 5.minutes, public: true unless @stream_entry.hidden?
|
||||
|
||||
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status'
|
||||
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
|
||||
|
@ -57,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?
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
pack:
|
||||
about:
|
||||
admin: admin.js
|
||||
auth:
|
||||
auth: settings.js
|
||||
common:
|
||||
filename: common.js
|
||||
stylesheet: true
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -68,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,
|
||||
|
@ -81,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);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -106,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);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -136,7 +138,8 @@ export function submitCompose(routerHistory) {
|
|||
return function (dispatch, getState) {
|
||||
let status = getState().getIn(['compose', 'text'], '');
|
||||
let media = getState().getIn(['compose', 'media_attachments']);
|
||||
let spoilerText = getState().getIn(['compose', 'spoiler_text'], '');
|
||||
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
|
||||
let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -55,7 +55,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||
} else {
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const emojiMap = makeEmojiMap(normalStatus);
|
||||
|
||||
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||
|
|
|
@ -11,7 +11,9 @@ import { saveSettings } from './settings';
|
|||
import { defineMessages } from 'react-intl';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { unescapeHTML } from 'flavours/glitch/util/html';
|
||||
import { getFilters, regexFromFilters } from 'flavours/glitch/selectors';
|
||||
import { getFiltersRegex } from 'flavours/glitch/selectors';
|
||||
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
|
||||
import compareId from 'flavours/glitch/util/compare_id';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
|
||||
|
@ -32,8 +34,9 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
|||
|
||||
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
|
||||
|
||||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
|
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
|
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
||||
|
||||
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
||||
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
||||
|
@ -52,19 +55,28 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const loadPending = () => ({
|
||||
type: NOTIFICATIONS_LOAD_PENDING,
|
||||
});
|
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
return (dispatch, getState) => {
|
||||
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
const filters = getFilters(getState(), { contextType: 'notifications' });
|
||||
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
|
||||
|
||||
let filtered = false;
|
||||
|
||||
if (notification.type === 'mention') {
|
||||
const regex = regexFromFilters(filters);
|
||||
const dropRegex = filters[0];
|
||||
const regex = filters[1];
|
||||
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
||||
|
||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
}
|
||||
|
||||
|
@ -78,6 +90,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE,
|
||||
notification,
|
||||
usePendingItems: preferPendingItems,
|
||||
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
|
||||
});
|
||||
|
||||
|
@ -131,10 +144,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
|||
: excludeTypesFromFilter(activeFilter),
|
||||
};
|
||||
|
||||
if (!maxId && notifications.get('items').size > 0) {
|
||||
params.since_id = notifications.getIn(['items', 0, 'id']);
|
||||
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
|
||||
const a = notifications.getIn(['pendingItems', 0, 'id']);
|
||||
const b = notifications.getIn(['items', 0, 'id']);
|
||||
|
||||
if (a && b && compareId(a, b) > 0) {
|
||||
params.since_id = a;
|
||||
} else {
|
||||
params.since_id = b || a;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingRecent = !!params.since_id;
|
||||
|
||||
dispatch(expandNotificationsRequest(isLoadingMore));
|
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => {
|
||||
|
@ -143,7 +165,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
|||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore));
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
done();
|
||||
}).catch(error => {
|
||||
|
@ -160,13 +182,12 @@ export function expandNotificationsRequest(isLoadingMore) {
|
|||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
|
||||
export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
||||
notifications,
|
||||
accounts: notifications.map(item => item.account),
|
||||
statuses: notifications.map(item => item.status).filter(status => !!status),
|
||||
next,
|
||||
usePendingItems,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -48,7 +48,7 @@ export function submitSearch() {
|
|||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data));
|
||||
dispatch(fetchSearchSuccess(response.data, value));
|
||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSearchFail(error));
|
||||
|
@ -62,12 +62,11 @@ export function fetchSearchRequest() {
|
|||
};
|
||||
};
|
||||
|
||||
export function fetchSearchSuccess(results) {
|
||||
export function fetchSearchSuccess(results, searchTerm) {
|
||||
return {
|
||||
type: SEARCH_FETCH_SUCCESS,
|
||||
results,
|
||||
accounts: results.accounts,
|
||||
statuses: results.statuses,
|
||||
searchTerm,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
@ -80,7 +81,7 @@ export function 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]);
|
||||
|
||||
|
@ -97,9 +98,7 @@ export function deleteStatus(id, router, withRedraft = false) {
|
|||
if (withRedraft) {
|
||||
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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import api, { getLinks } from 'flavours/glitch/util/api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import compareId from 'flavours/glitch/util/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
|
@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
|||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||
export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
|
||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||
|
||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||
export const loadPending = timeline => ({
|
||||
type: TIMELINE_LOAD_PENDING,
|
||||
timeline,
|
||||
});
|
||||
|
||||
export function updateTimeline(timeline, status, accept) {
|
||||
return dispatch => {
|
||||
|
@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) {
|
|||
type: TIMELINE_UPDATE,
|
||||
timeline,
|
||||
status,
|
||||
usePendingItems: preferPendingItems,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
|
||||
params.since_id = timeline.getIn(['items', 0]);
|
||||
if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) {
|
||||
const a = timeline.getIn(['pendingItems', 0]);
|
||||
const b = timeline.getIn(['items', 0]);
|
||||
|
||||
if (a && b && compareId(a, b) > 0) {
|
||||
params.since_id = a;
|
||||
} else {
|
||||
params.since_id = b || a;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingRecent = !!params.since_id;
|
||||
|
@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||
api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
|
@ -117,7 +132,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) {
|
|||
};
|
||||
};
|
||||
|
||||
export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) {
|
||||
export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline,
|
||||
|
@ -125,6 +140,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
|
|||
next,
|
||||
partial,
|
||||
isLoadingRecent,
|
||||
usePendingItems,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
|
@ -153,9 +169,8 @@ export function connectTimeline(timeline) {
|
|||
};
|
||||
};
|
||||
|
||||
export function disconnectTimeline(timeline) {
|
||||
return {
|
||||
type: TIMELINE_DISCONNECT,
|
||||
timeline,
|
||||
};
|
||||
};
|
||||
export const disconnectTimeline = timeline => ({
|
||||
type: TIMELINE_DISCONNECT,
|
||||
timeline,
|
||||
usePendingItems: preferPendingItems,
|
||||
});
|
||||
|
|
|
@ -49,7 +49,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
autoFocus: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
searchTokens: PropTypes.list,
|
||||
searchTokens: PropTypes.arrayOf(PropTypes.string),
|
||||
maxLength: PropTypes.number,
|
||||
};
|
||||
|
||||
|
|
|
@ -138,8 +138,11 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
onFocus = (e) => {
|
||||
this.setState({ focused: true });
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(e);
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
|
@ -189,7 +192,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
|
@ -197,34 +200,39 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
return [
|
||||
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
inputRef={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
</label>
|
||||
<Textarea
|
||||
inputRef={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{children}
|
||||
</div>,
|
||||
|
||||
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
export default class AvatarComposite extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accounts: ImmutablePropTypes.list.isRequired,
|
||||
animate: PropTypes.bool,
|
||||
size: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: autoPlayGif,
|
||||
};
|
||||
|
||||
renderItem (account, size, index) {
|
||||
const { animate } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
let top = 'auto';
|
||||
let left = 'auto';
|
||||
let bottom = 'auto';
|
||||
let right = 'auto';
|
||||
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
if (size === 4 || (size === 3 && index > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
|
||||
if (size === 2) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else {
|
||||
left = '2px';
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else if (index > 0) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index === 1) {
|
||||
bottom = '2px';
|
||||
} else if (index > 1) {
|
||||
top = '2px';
|
||||
}
|
||||
} else if (size === 4) {
|
||||
if (index === 0 || index === 2) {
|
||||
right = '2px';
|
||||
}
|
||||
|
||||
if (index === 1 || index === 3) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index < 2) {
|
||||
bottom = '2px';
|
||||
} else {
|
||||
top = '2px';
|
||||
}
|
||||
}
|
||||
|
||||
const style = {
|
||||
left: left,
|
||||
top: top,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: `${width}%`,
|
||||
height: `${height}%`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={account.get('url')}
|
||||
target='_blank'
|
||||
onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
|
||||
title={`@${account.get('acct')}`}
|
||||
key={account.get('id')}
|
||||
>
|
||||
<div style={style} data-avatar-of={`@${account.get('acct')}`} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { accounts, size } = this.props;
|
||||
|
||||
return (
|
||||
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
|
||||
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -10,24 +10,56 @@ export default function DisplayName ({
|
|||
className,
|
||||
inline,
|
||||
localDomain,
|
||||
others,
|
||||
onAccountClick,
|
||||
}) {
|
||||
const computedClass = classNames('display-name', { inline }, className);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
let displayName, suffix;
|
||||
|
||||
let acct = account.get('acct');
|
||||
|
||||
if (acct.indexOf('@') === -1 && localDomain) {
|
||||
acct = `${acct}@${localDomain}`;
|
||||
}
|
||||
|
||||
// The result.
|
||||
return account ? (
|
||||
if (others && others.size > 0) {
|
||||
displayName = others.take(2).map(a => (
|
||||
<a
|
||||
href={a.get('url')}
|
||||
target='_blank'
|
||||
onClick={(e) => onAccountClick(a.get('id'), e)}
|
||||
title={`@${a.get('acct')}`}
|
||||
>
|
||||
<bdi key={a.get('id')}>
|
||||
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
|
||||
</bdi>
|
||||
</a>
|
||||
)).reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
if (others.size - 2 > 0) {
|
||||
displayName.push(` +${others.size - 2}`);
|
||||
}
|
||||
|
||||
suffix = (
|
||||
<a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)}>
|
||||
<span className='display-name__account'>@{acct}</span>
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
|
||||
suffix = <span className='display-name__account'>@{acct}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={computedClass}>
|
||||
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
|
||||
{displayName}
|
||||
{inline ? ' ' : null}
|
||||
<span className='display-name__account'>@{acct}</span>
|
||||
{suffix}
|
||||
</span>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
// Props.
|
||||
|
@ -36,4 +68,6 @@ DisplayName.propTypes = {
|
|||
className: PropTypes.string,
|
||||
inline: PropTypes.bool,
|
||||
localDomain: PropTypes.string,
|
||||
others: ImmutablePropTypes.list,
|
||||
handleClick: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -50,43 +50,43 @@ export default class ErrorBoundary extends React.PureComponent {
|
|||
<h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
|
||||
<p>
|
||||
<FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.report_issue'
|
||||
defaultMessage='Report a bug in the {issuetracker}'
|
||||
values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
|
||||
/>
|
||||
{ debugInfo !== '' && (
|
||||
<details>
|
||||
<summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
|
||||
<textarea
|
||||
className='web_app_crash-stacktrace'
|
||||
value={debugInfo}
|
||||
rows='10'
|
||||
readOnly
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.reload_page'
|
||||
defaultMessage='{reload} the current page'
|
||||
values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
|
||||
/>
|
||||
</li>
|
||||
{ preferencesLink !== undefined && (
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.change_your_settings'
|
||||
defaultMessage='Change your {settings}'
|
||||
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.report_issue'
|
||||
defaultMessage='Report a bug in the {issuetracker}'
|
||||
values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
|
||||
/>
|
||||
{ debugInfo !== '' && (
|
||||
<details>
|
||||
<summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
|
||||
<textarea
|
||||
className='web_app_crash-stacktrace'
|
||||
value={debugInfo}
|
||||
rows='10'
|
||||
readOnly
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.reload_page'
|
||||
defaultMessage='{reload} the current page'
|
||||
values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
|
||||
/>
|
||||
</li>
|
||||
{ preferencesLink !== undefined && (
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.change_your_settings'
|
||||
defaultMessage='Change your {settings}'
|
||||
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
|
||||
const formatNumber = num => num > 40 ? '40+' : num;
|
||||
|
||||
const IconWithBadge = ({ id, count, className }) => (
|
||||
<i className='icon-with-badge'>
|
||||
<Icon icon={id} fixedWidth className={className} />
|
||||
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
|
||||
</i>
|
||||
);
|
||||
|
||||
IconWithBadge.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default IconWithBadge;
|
|
@ -1,10 +1,12 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
|
||||
import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry';
|
||||
|
||||
export default class IntersectionObserverArticle extends ImmutablePureComponent {
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
||||
export default class IntersectionObserverArticle extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
|
@ -22,20 +24,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
|
|||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
if (!nextState.isIntersecting && nextState.isHidden) {
|
||||
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
|
||||
// that either "isIntersecting" or "isHidden" matter, and then they're
|
||||
// the only things that matter (and updated ARIA attributes).
|
||||
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
|
||||
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
|
||||
// If we're going from a non-intersecting state to an intersecting state,
|
||||
// (i.e. offscreen to onscreen), then we definitely need to re-render
|
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
|
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
|
||||
if (!!isUnrendered !== !!willBeUnrendered) {
|
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
return true;
|
||||
}
|
||||
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
|
||||
return super.shouldComponentUpdate(nextProps, nextState);
|
||||
// If we are and remain hidden, diff based on props
|
||||
if (isUnrendered) {
|
||||
return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
|
||||
}
|
||||
// Else, assume the children have changed
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
componentDidMount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
|
||||
|
@ -119,7 +122,7 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
|
|||
data-id={id}
|
||||
tabIndex='0'
|
||||
style={style}>
|
||||
{children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || cachedHeight) })}
|
||||
{children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class LoadPending extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
count: PropTypes.number,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { count } = this.props;
|
||||
|
||||
return (
|
||||
<button className='load-more load-gap' onClick={this.props.onClick}>
|
||||
<FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,7 @@ import IconButton from './icon_button';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from 'flavours/glitch/util/is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -101,6 +101,8 @@ class Item extends React.PureComponent {
|
|||
}
|
||||
|
||||
_decode () {
|
||||
if (!useBlurhash) return;
|
||||
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
|
@ -177,7 +179,7 @@ class Item extends React.PureComponent {
|
|||
if (attachment.get('type') === 'unknown') {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
</a>
|
||||
</div>
|
||||
|
@ -257,7 +259,6 @@ export default class MediaGallery extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
sensitive: PropTypes.bool,
|
||||
revealed: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
letterbox: PropTypes.bool,
|
||||
fullwidth: PropTypes.bool,
|
||||
|
@ -268,6 +269,8 @@ export default class MediaGallery extends React.PureComponent {
|
|||
intl: PropTypes.object.isRequired,
|
||||
defaultWidth: PropTypes.number,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -275,13 +278,15 @@ export default class MediaGallery extends React.PureComponent {
|
|||
};
|
||||
|
||||
state = {
|
||||
visible: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed,
|
||||
visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
||||
width: this.props.defaultWidth,
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) {
|
||||
this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed });
|
||||
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
|
||||
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
|
||||
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
this.setState({ visible: nextProps.visible });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -294,7 +299,11 @@ export default class MediaGallery extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (index) => {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4';
|
|||
import PropTypes from 'prop-types';
|
||||
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
|
||||
import LoadMore from './load_more';
|
||||
import LoadPending from './load_pending';
|
||||
import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
|
||||
import { throttle } from 'lodash';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent {
|
|||
static propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
onLoadMore: PropTypes.func,
|
||||
onLoadPending: PropTypes.func,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
trackScroll: PropTypes.bool,
|
||||
|
@ -28,6 +30,7 @@ export default class ScrollableList extends PureComponent {
|
|||
isLoading: PropTypes.bool,
|
||||
showLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
numPending: PropTypes.number,
|
||||
prepend: PropTypes.node,
|
||||
alwaysPrepend: PropTypes.bool,
|
||||
emptyMessage: PropTypes.node,
|
||||
|
@ -222,12 +225,18 @@ export default class ScrollableList extends PureComponent {
|
|||
return !(location.state && location.state.mastodonModalOpen);
|
||||
}
|
||||
|
||||
handleLoadPending = e => {
|
||||
e.preventDefault();
|
||||
this.props.onLoadPending();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
|
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
const childrenCount = React.Children.count(children);
|
||||
|
||||
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
|
||||
const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null;
|
||||
let scrollableArea = null;
|
||||
|
||||
if (showLoading) {
|
||||
|
@ -248,6 +257,8 @@ export default class ScrollableList extends PureComponent {
|
|||
<div role='feed' className='item-list'>
|
||||
{prepend}
|
||||
|
||||
{loadPending}
|
||||
|
||||
{React.Children.map(this.props.children, (child, index) => (
|
||||
<IntersectionObserverArticleContainer
|
||||
key={child.key}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default
|
||||
class Spoilers extends React.PureComponent {
|
||||
static propTypes = {
|
||||
spoilerText: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
}
|
||||
|
||||
handleSpoilerClick = () => {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { spoilerText, children } = this.props;
|
||||
const { hidden } = this.state;
|
||||
|
||||
const toggleText = hidden ?
|
||||
<FormattedMessage
|
||||
id='status.show_more'
|
||||
defaultMessage='Show more'
|
||||
key='0'
|
||||
/> :
|
||||
<FormattedMessage
|
||||
id='status.show_less'
|
||||
defaultMessage='Show less'
|
||||
key='0'
|
||||
/>;
|
||||
|
||||
return ([
|
||||
<p className='spoiler__text'>
|
||||
{spoilerText}
|
||||
{' '}
|
||||
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
|
||||
{toggleText}
|
||||
</button>
|
||||
</p>,
|
||||
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications
|
|||
import classNames from 'classnames';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { displayMedia } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
|
@ -38,8 +39,24 @@ export const textForScreenReader = (intl, status, rebloggedByText = false, expan
|
|||
return values.join(', ');
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class Status extends ImmutablePureComponent {
|
||||
export const defaultMediaVisibility = (status, settings) => {
|
||||
if (!status) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||
}
|
||||
|
||||
export default @injectIntl
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
|
@ -49,6 +66,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
containerId: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
status: ImmutablePropTypes.map,
|
||||
otherAccounts: ImmutablePropTypes.list,
|
||||
account: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
|
@ -66,6 +84,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
muted: PropTypes.bool,
|
||||
collapse: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
prepend: PropTypes.string,
|
||||
withDismiss: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
|
@ -76,12 +95,18 @@ export default class Status extends ImmutablePureComponent {
|
|||
intl: PropTypes.object.isRequired,
|
||||
cacheMediaWidth: PropTypes.func,
|
||||
cachedMediaWidth: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
isCollapsed: false,
|
||||
autoCollapsed: false,
|
||||
isExpanded: undefined,
|
||||
showMedia: undefined,
|
||||
statusId: undefined,
|
||||
revealBehindCW: undefined,
|
||||
showCard: false,
|
||||
forceFilter: undefined,
|
||||
}
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
@ -91,8 +116,6 @@ export default class Status extends ImmutablePureComponent {
|
|||
'account',
|
||||
'settings',
|
||||
'prepend',
|
||||
'boostModal',
|
||||
'favouriteModal',
|
||||
'muted',
|
||||
'collapse',
|
||||
'notification',
|
||||
|
@ -103,6 +126,8 @@ export default class Status extends ImmutablePureComponent {
|
|||
updateOnStates = [
|
||||
'isExpanded',
|
||||
'isCollapsed',
|
||||
'showMedia',
|
||||
'forceFilter',
|
||||
]
|
||||
|
||||
// If our settings have changed to disable collapsed statuses, then we
|
||||
|
@ -160,6 +185,20 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
|
||||
update.statusId = nextProps.status.get('id');
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
|
||||
update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
|
||||
if (update.revealBehindCW) {
|
||||
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
|
||||
return updated ? update : null;
|
||||
}
|
||||
|
||||
|
@ -219,28 +258,32 @@ export default class Status extends ImmutablePureComponent {
|
|||
this.setState({ autoCollapsed: true });
|
||||
}
|
||||
|
||||
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
|
||||
// Hack to fix timeline jumps when a preview card is fetched
|
||||
this.setState({
|
||||
showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
|
||||
});
|
||||
}
|
||||
|
||||
// Hack to fix timeline jumps on second rendering when auto-collapsing
|
||||
// or on subsequent rendering when a preview card has been fetched
|
||||
getSnapshotBeforeUpdate (prevProps, prevState) {
|
||||
if (this.props.getScrollPosition) {
|
||||
if (!this.props.getScrollPosition) return null;
|
||||
|
||||
const { muted, hidden, status, settings } = this.props;
|
||||
|
||||
const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
|
||||
if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
|
||||
if (doShowCard) this.setState({ showCard: true });
|
||||
if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
|
||||
return this.props.getScrollPosition();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Hack to fix timeline jumps on second rendering when auto-collapsing
|
||||
componentDidUpdate (prevProps, prevState, snapshot) {
|
||||
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
|
||||
if (this.state.autoCollapsed || (doShowCard && !this.didShowCard)) {
|
||||
if (doShowCard) this.didShowCard = true;
|
||||
if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
|
||||
if (snapshot !== null && this.props.updateScrollBottom) {
|
||||
if (this.node.offsetTop < snapshot.top) {
|
||||
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
||||
}
|
||||
}
|
||||
if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
|
||||
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -286,17 +329,21 @@ export default class Status extends ImmutablePureComponent {
|
|||
const { status } = this.props;
|
||||
const { isCollapsed } = this.state;
|
||||
if (!router) return;
|
||||
if (destination === undefined) {
|
||||
destination = `/statuses/${
|
||||
status.getIn(['reblog', 'id'], status.get('id'))
|
||||
}`;
|
||||
}
|
||||
|
||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (isCollapsed) this.setCollapsed(false);
|
||||
else if (e.shiftKey) {
|
||||
this.setCollapsed(true);
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
} else {
|
||||
if (destination === undefined) {
|
||||
destination = `/statuses/${
|
||||
status.getIn(['reblog', 'id'], status.get('id'))
|
||||
}`;
|
||||
}
|
||||
let state = {...router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
router.history.push(destination, state);
|
||||
|
@ -305,6 +352,10 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
|
@ -374,6 +425,18 @@ export default class Status extends ImmutablePureComponent {
|
|||
this.setCollapsed(!this.state.isCollapsed);
|
||||
}
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
}
|
||||
|
||||
handleUnfilterClick = e => {
|
||||
const { onUnfilter, status } = this.props;
|
||||
onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false }));
|
||||
}
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.setState({ forceFilter: true });
|
||||
}
|
||||
|
||||
handleRef = c => {
|
||||
this.node = c;
|
||||
|
@ -399,6 +462,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
intl,
|
||||
status,
|
||||
account,
|
||||
otherAccounts,
|
||||
settings,
|
||||
collapsed,
|
||||
muted,
|
||||
|
@ -408,6 +472,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
onOpenMedia,
|
||||
notification,
|
||||
hidden,
|
||||
unread,
|
||||
featured,
|
||||
...other
|
||||
} = this.props;
|
||||
|
@ -431,7 +496,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
||||
if ((status.get('filtered') || status.getIn(['reblog', 'filtered'])) && (this.state.forceFilter === true || settings.get('filtering_behavior') !== 'content_warning')) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -441,6 +506,12 @@ export default class Status extends ImmutablePureComponent {
|
|||
<HotKeys handlers={minHandlers}>
|
||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
{settings.get('filtering_behavior') !== 'upstream' && ' '}
|
||||
{settings.get('filtering_behavior') !== 'upstream' && (
|
||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
@ -472,16 +543,16 @@ export default class Status extends ImmutablePureComponent {
|
|||
media={status.get('media_attachments')}
|
||||
/>
|
||||
);
|
||||
} else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
} else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
preview={attachment.get('preview_url')}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
|
@ -490,11 +561,12 @@ export default class Status extends ImmutablePureComponent {
|
|||
onOpenVideo={this.handleOpenVideo}
|
||||
width={this.props.cachedMediaWidth}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>)}
|
||||
</Bundle>
|
||||
);
|
||||
mediaIcon = 'video-camera';
|
||||
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
|
||||
} else { // Media type is 'image' or 'gifv'
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
|
@ -508,7 +580,8 @@ export default class Status extends ImmutablePureComponent {
|
|||
onOpenMedia={this.props.onOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
|
@ -566,12 +639,14 @@ export default class Status extends ImmutablePureComponent {
|
|||
toggleSpoiler: this.handleExpandedToggle,
|
||||
bookmark: this.handleHotkeyBookmark,
|
||||
toggleCollapse: this.handleHotkeyCollapse,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
};
|
||||
|
||||
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
|
||||
collapsed: isCollapsed,
|
||||
'has-background': isCollapsed && background,
|
||||
'status__wrapper-reply': !!status.get('in_reply_to_id'),
|
||||
read: unread === false,
|
||||
muted,
|
||||
}, 'focusable');
|
||||
|
||||
|
@ -602,6 +677,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
friend={account}
|
||||
collapsed={isCollapsed}
|
||||
parseClick={parseClick}
|
||||
otherAccounts={otherAccounts}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
|
@ -611,6 +687,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
collapsible={settings.getIn(['collapsed', 'enabled'])}
|
||||
collapsed={isCollapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
directMessage={!!otherAccounts}
|
||||
/>
|
||||
</header>
|
||||
<StatusContent
|
||||
|
@ -628,6 +705,8 @@ export default class Status extends ImmutablePureComponent {
|
|||
status={status}
|
||||
account={status.get('account')}
|
||||
showReplyCount={settings.get('show_reply_count')}
|
||||
directMessage={!!otherAccounts}
|
||||
onFilter={this.handleFilterClick}
|
||||
/>
|
||||
) : null}
|
||||
{notification ? (
|
||||
|
|
|
@ -35,6 +35,7 @@ const messages = defineMessages({
|
|||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
|
||||
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
|
||||
});
|
||||
|
||||
const obfuscatedCount = count => {
|
||||
|
@ -69,8 +70,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onFilter: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
showReplyCount: PropTypes.bool,
|
||||
directMessage: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -151,8 +154,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
handleOpen = () => {
|
||||
let state = {...this.context.router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
|
||||
if (state.mastodonModalOpen) {
|
||||
this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
|
||||
} else {
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmbed = () => {
|
||||
|
@ -186,8 +193,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.props.onFilter();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss, showReplyCount } = this.props;
|
||||
const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
|
@ -258,6 +269,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
const filterButton = status.get('filtered') && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
|
||||
);
|
||||
|
||||
let replyButton = (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
|
@ -278,14 +293,16 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='status__action-bar'>
|
||||
{replyButton}
|
||||
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{shareButton}
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
{!directMessage && [
|
||||
<IconButton key='reblog-button' className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
|
||||
<IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
|
||||
shareButton,
|
||||
<IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
|
||||
filterButton,
|
||||
<div key='dropdown-button' className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
|
||||
</div>,
|
||||
]}
|
||||
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
|
|
|
@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
// Mastodon imports.
|
||||
import Avatar from './avatar';
|
||||
import AvatarOverlay from './avatar_overlay';
|
||||
import AvatarComposite from './avatar_composite';
|
||||
import DisplayName from './display_name';
|
||||
|
||||
export default class StatusHeader extends React.PureComponent {
|
||||
|
@ -14,12 +15,18 @@ export default class StatusHeader extends React.PureComponent {
|
|||
status: ImmutablePropTypes.map.isRequired,
|
||||
friend: ImmutablePropTypes.map,
|
||||
parseClick: PropTypes.func.isRequired,
|
||||
otherAccounts: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
// Handles clicks on account name/image
|
||||
handleClick = (id, e) => {
|
||||
const { parseClick } = this.props;
|
||||
parseClick(e, `/accounts/${id}`);
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
const { status, parseClick } = this.props;
|
||||
parseClick(e, `/accounts/${status.getIn(['account', 'id'])}`);
|
||||
const { status } = this.props;
|
||||
this.handleClick(status.getIn(['account', 'id']), e);
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
|
@ -27,36 +34,55 @@ export default class StatusHeader extends React.PureComponent {
|
|||
const {
|
||||
status,
|
||||
friend,
|
||||
otherAccounts,
|
||||
} = this.props;
|
||||
|
||||
const account = status.get('account');
|
||||
|
||||
return (
|
||||
<div className='status__info__account' >
|
||||
<a
|
||||
href={account.get('url')}
|
||||
target='_blank'
|
||||
className='status__avatar'
|
||||
onClick={this.handleAccountClick}
|
||||
>
|
||||
{
|
||||
friend ? (
|
||||
<AvatarOverlay account={account} friend={friend} />
|
||||
) : (
|
||||
<Avatar account={account} size={48} />
|
||||
)
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
href={account.get('url')}
|
||||
target='_blank'
|
||||
className='status__display-name'
|
||||
onClick={this.handleAccountClick}
|
||||
>
|
||||
<DisplayName account={account} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
let statusAvatar;
|
||||
if (otherAccounts && otherAccounts.size > 0) {
|
||||
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />;
|
||||
} else if (friend === undefined || friend === null) {
|
||||
statusAvatar = <Avatar account={account} size={48} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={account} friend={friend} />;
|
||||
}
|
||||
|
||||
if (!otherAccounts) {
|
||||
return (
|
||||
<div className='status__info__account'>
|
||||
<a
|
||||
href={account.get('url')}
|
||||
target='_blank'
|
||||
className='status__avatar'
|
||||
onClick={this.handleAccountClick}
|
||||
>
|
||||
{statusAvatar}
|
||||
</a>
|
||||
<a
|
||||
href={account.get('url')}
|
||||
target='_blank'
|
||||
className='status__display-name'
|
||||
onClick={this.handleAccountClick}
|
||||
>
|
||||
<DisplayName account={account} others={otherAccounts} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// This is a DM conversation
|
||||
return (
|
||||
<div className='status__info__account'>
|
||||
<span className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</span>
|
||||
|
||||
<span className='status__display-name'>
|
||||
<DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,6 +12,13 @@ import VisibilityIcon from './status_visibility_icon';
|
|||
const messages = defineMessages({
|
||||
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
|
||||
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
|
||||
inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' },
|
||||
previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' },
|
||||
pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' },
|
||||
poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' },
|
||||
video: { id: 'status.has_video', defaultMessage: 'Features attached videos' },
|
||||
audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' },
|
||||
localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
|
@ -22,6 +29,7 @@ export default class StatusIcons extends React.PureComponent {
|
|||
mediaIcon: PropTypes.string,
|
||||
collapsible: PropTypes.bool,
|
||||
collapsed: PropTypes.bool,
|
||||
directMessage: PropTypes.bool,
|
||||
setCollapsed: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
@ -35,6 +43,23 @@ export default class StatusIcons extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
mediaIconTitleText () {
|
||||
const { intl, mediaIcon } = this.props;
|
||||
|
||||
switch (mediaIcon) {
|
||||
case 'link':
|
||||
return intl.formatMessages(message.previewCard);
|
||||
case 'picture-o':
|
||||
return intl.formatMessage(messages.pictures);
|
||||
case 'tasks':
|
||||
return intl.formatMessage(messages.poll);
|
||||
case 'video-camera':
|
||||
return intl.formatMessage(messages.video);
|
||||
case 'music':
|
||||
return intl.formatMessage(messages.audio);
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const {
|
||||
|
@ -42,6 +67,7 @@ export default class StatusIcons extends React.PureComponent {
|
|||
mediaIcon,
|
||||
collapsible,
|
||||
collapsed,
|
||||
directMessage,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
|
@ -51,17 +77,23 @@ export default class StatusIcons extends React.PureComponent {
|
|||
<i
|
||||
className={`fa fa-fw fa-comment status__reply-icon`}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.inReplyTo)}
|
||||
/>
|
||||
) : null}
|
||||
{status.get('local_only') &&
|
||||
<i
|
||||
className={`fa fa-fw fa-home`}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.localOnly)}
|
||||
/>}
|
||||
{mediaIcon ? (
|
||||
<i
|
||||
className={`fa fa-fw fa-${mediaIcon} status__media-icon`}
|
||||
aria-hidden='true'
|
||||
title={this.mediaIconTitleText()}
|
||||
/>
|
||||
) : null}
|
||||
{(
|
||||
<VisibilityIcon visibility={status.get('visibility')} />
|
||||
)}
|
||||
{!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||
{collapsible ? (
|
||||
<IconButton
|
||||
className='status__collapse-button'
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Status from 'flavours/glitch/components/status';
|
||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
@ -25,7 +26,11 @@ import { openModal } from 'flavours/glitch/actions/modal';
|
|||
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
|
||||
import { filterEditLink } from 'flavours/glitch/util/backend_links';
|
||||
import { showAlertForError } from '../actions/alerts';
|
||||
import AccountContainer from 'flavours/glitch/containers/account_container';
|
||||
import Spoilers from '../components/spoilers';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
|
@ -36,6 +41,10 @@ const messages = defineMessages({
|
|||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
|
||||
author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
|
||||
matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
|
||||
editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
|
@ -69,7 +78,7 @@ const makeMapStateToProps = () => {
|
|||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
||||
|
||||
onReply (status, router) {
|
||||
dispatch((_, getState) => {
|
||||
|
@ -96,11 +105,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
||||
}
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
|
||||
} else if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onBookmark (status) {
|
||||
|
@ -184,6 +198,48 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
}));
|
||||
},
|
||||
|
||||
onUnfilter (status, onConfirm) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
const serverSideType = toServerSideType(contextType);
|
||||
const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
|
||||
const searchIndex = status.get('search_index');
|
||||
const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex));
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: [
|
||||
<FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />,
|
||||
<div className='filtered-status-info'>
|
||||
<Spoilers spoilerText={intl.formatMessage(messages.author)}>
|
||||
<AccountContainer id={status.getIn(['account', 'id'])} />
|
||||
</Spoilers>
|
||||
<Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}>
|
||||
<ul>
|
||||
{matchingFilters.map(filter => (
|
||||
<li>
|
||||
{filter.get('phrase')}
|
||||
{!!filterEditLink && ' '}
|
||||
{!!filterEditLink && (
|
||||
<a
|
||||
target='_blank'
|
||||
className='filtered-status-edit-link'
|
||||
title={intl.formatMessage(messages.editFilter)}
|
||||
href={filterEditLink(filter.get('id'))}
|
||||
>
|
||||
<Icon icon='pencil' />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Spoilers>
|
||||
</div>
|
||||
],
|
||||
confirm: intl.formatMessage(messages.unfilterConfirm),
|
||||
onConfirm: onConfirm,
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
onReport (status) {
|
||||
dispatch(initReport(status.get('account'), status));
|
||||
},
|
||||
|
|
|
@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
return (
|
||||
<div>
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
|
||||
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
|
||||
</div>
|
||||
|
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
|
|
|
@ -55,6 +55,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
onPickEmoji: PropTypes.func,
|
||||
showSearch: PropTypes.bool,
|
||||
anyMedia: PropTypes.bool,
|
||||
singleColumn: PropTypes.bool,
|
||||
|
||||
advancedOptions: ImmutablePropTypes.map,
|
||||
layout: PropTypes.string,
|
||||
|
@ -66,8 +67,6 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
preselectOnReply: PropTypes.bool,
|
||||
onChangeSpoilerness: PropTypes.func,
|
||||
onChangeVisibility: PropTypes.func,
|
||||
onMount: PropTypes.func,
|
||||
onUnmount: PropTypes.func,
|
||||
onPaste: PropTypes.func,
|
||||
onMediaDescriptionConfirm: PropTypes.func,
|
||||
};
|
||||
|
@ -141,6 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.composeForm = c;
|
||||
};
|
||||
|
||||
// Inserts an emoji at the caret.
|
||||
handleEmoji = (data) => {
|
||||
const { textarea: { selectionStart } } = this;
|
||||
|
@ -192,19 +195,12 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
// Tells our state the composer has been mounted.
|
||||
componentDidMount () {
|
||||
const { onMount } = this.props;
|
||||
if (onMount) {
|
||||
onMount();
|
||||
}
|
||||
}
|
||||
|
||||
// Tells our state the composer has been unmounted.
|
||||
componentWillUnmount () {
|
||||
const { onUnmount } = this.props;
|
||||
if (onUnmount) {
|
||||
onUnmount();
|
||||
handleFocus = () => {
|
||||
if (this.composeForm && !this.props.singleColumn) {
|
||||
const { left, right } = this.composeForm.getBoundingClientRect();
|
||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||
this.composeForm.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -227,6 +223,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
preselectDate,
|
||||
text,
|
||||
preselectOnReply,
|
||||
singleColumn,
|
||||
} = this.props;
|
||||
let selectionEnd, selectionStart;
|
||||
|
||||
|
@ -246,7 +243,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
if (textarea) {
|
||||
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
textarea.focus();
|
||||
textarea.scrollIntoView();
|
||||
if (!singleColumn) textarea.scrollIntoView();
|
||||
}
|
||||
|
||||
// Refocuses the textarea after submitting.
|
||||
|
@ -307,7 +304,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
|
||||
<ReplyIndicatorContainer />
|
||||
|
||||
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
|
||||
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={spoilerText}
|
||||
|
@ -323,34 +320,32 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
searchTokens={[':']}
|
||||
id='glitch.composer.spoiler.input'
|
||||
className='spoiler-input__input'
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='composer--textarea'>
|
||||
<TextareaIcons advancedOptions={advancedOptions} />
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onSuggestionsFetchRequested={onFetchSuggestions}
|
||||
onSuggestionsClearRequested={onClearSuggestions}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
|
||||
/>
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onSuggestionsFetchRequested={onFetchSuggestions}
|
||||
onSuggestionsClearRequested={onClearSuggestions}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
|
||||
>
|
||||
<EmojiPicker onPickEmoji={handleEmoji} />
|
||||
</div>
|
||||
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
<PollFormContainer />
|
||||
</div>
|
||||
<TextareaIcons advancedOptions={advancedOptions} />
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
<PollFormContainer />
|
||||
</div>
|
||||
</AutosuggestTextarea>
|
||||
|
||||
<OptionsContainer
|
||||
advancedOptions={advancedOptions}
|
||||
|
|
|
@ -17,19 +17,21 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
<div className='drawer--account'>
|
||||
<Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
|
||||
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
|
||||
<Avatar account={this.props.account} size={40} />
|
||||
<Avatar account={this.props.account} size={48} />
|
||||
</Permalink>
|
||||
|
||||
<Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
|
||||
<strong>@{this.props.account.get('acct')}</strong>
|
||||
</Permalink>
|
||||
<div className='navigation-bar__profile'>
|
||||
<Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
|
||||
<strong>@{this.props.account.get('acct')}</strong>
|
||||
</Permalink>
|
||||
|
||||
{ profileLink !== undefined && (
|
||||
<a
|
||||
className='edit'
|
||||
href={ profileLink }
|
||||
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
)}
|
||||
{ profileLink !== undefined && (
|
||||
<a
|
||||
className='edit'
|
||||
href={ profileLink }
|
||||
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -232,7 +232,7 @@ class ComposerOptions extends ImmutablePureComponent {
|
|||
|
||||
const contentTypeItems = {
|
||||
plain: {
|
||||
icon: 'align-left',
|
||||
icon: 'file-text',
|
||||
name: 'text/plain',
|
||||
text: <FormattedMessage {...messages.plain} />,
|
||||
},
|
||||
|
|
|
@ -26,7 +26,7 @@ export default @injectIntl
|
|||
class ReplyIndicator extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -33,10 +33,10 @@ class SearchPopout extends React.PureComponent {
|
|||
const { style } = this.props;
|
||||
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
|
||||
return (
|
||||
<div style={{ ...style, position: 'absolute', width: 285 }}>
|
||||
<div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
|
||||
|
||||
<ul>
|
||||
|
@ -60,6 +60,10 @@ class SearchPopout extends React.PureComponent {
|
|||
export default @injectIntl
|
||||
class Search extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
submitted: PropTypes.bool,
|
||||
|
@ -67,6 +71,7 @@ class Search extends React.PureComponent {
|
|||
onSubmit: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func.isRequired,
|
||||
openInRoute: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -109,8 +114,10 @@ class Search extends React.PureComponent {
|
|||
const { onSubmit } = this.props;
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (onSubmit) {
|
||||
onSubmit();
|
||||
onSubmit();
|
||||
|
||||
if (this.props.openInRoute) {
|
||||
this.context.router.history.push('/search');
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
|
@ -121,14 +128,14 @@ class Search extends React.PureComponent {
|
|||
render () {
|
||||
const { intl, value, submitted } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const active = value.length > 0 || submitted;
|
||||
const computedClass = classNames('drawer--search', { active });
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
<div className={computedClass}>
|
||||
<div className='search'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
|
||||
<input
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value || ''}
|
||||
|
@ -138,17 +145,19 @@ class Search extends React.PureComponent {
|
|||
onBlur={this.handleBlur}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
className='icon'
|
||||
className='search__icon'
|
||||
onClick={this.handleClear}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
>
|
||||
<Icon icon='search' />
|
||||
<Icon icon='times-circle' />
|
||||
<Icon icon='search' className={hasValue ? '' : 'active'} />
|
||||
<Icon icon='times-circle' className={hasValue ? 'active' : ''} />
|
||||
</div>
|
||||
<Overlay show={expanded && !active} placement='bottom' target={this}>
|
||||
|
||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
|
||||
<SearchPopout />
|
||||
</Overlay>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
||||
|
@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
|
|||
suggestions: ImmutablePropTypes.list.isRequired,
|
||||
fetchSuggestions: PropTypes.func.isRequired,
|
||||
dismissSuggestion: PropTypes.func.isRequired,
|
||||
searchTerm: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -27,8 +29,8 @@ class SearchResults extends ImmutablePureComponent {
|
|||
this.props.fetchSuggestions();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, results, suggestions, dismissSuggestion } = this.props;
|
||||
render () {
|
||||
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
||||
|
||||
if (results.isEmpty() && !suggestions.isEmpty()) {
|
||||
return (
|
||||
|
@ -51,6 +53,16 @@ class SearchResults extends ImmutablePureComponent {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
||||
statuses = (
|
||||
<section>
|
||||
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
||||
|
||||
<div className='search-results__info'>
|
||||
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
let accounts, statuses, hashtags;
|
||||
|
|
|
@ -9,10 +9,8 @@ import {
|
|||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
insertEmojiCompose,
|
||||
mountCompose,
|
||||
selectComposeSuggestion,
|
||||
submitCompose,
|
||||
unmountCompose,
|
||||
uploadCompose,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import {
|
||||
|
@ -114,14 +112,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
dispatch(changeComposeVisibility(value));
|
||||
},
|
||||
|
||||
onMount() {
|
||||
dispatch(mountCompose());
|
||||
},
|
||||
|
||||
onUnmount() {
|
||||
dispatch(unmountCompose());
|
||||
},
|
||||
|
||||
onMediaDescriptionConfirm(routerHistory) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.missingDescriptionMessage),
|
||||
|
|
|
@ -16,7 +16,7 @@ function mapStateToProps (state) {
|
|||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||
hasPoll: !!poll,
|
||||
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true),
|
||||
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : true),
|
||||
hasMedia: media && !!media.size,
|
||||
allowPoll: !(media && !!media.size),
|
||||
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
|
||||
|
|
|
@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
|
|||
const mapStateToProps = state => ({
|
||||
results: state.getIn(['search', 'results']),
|
||||
suggestions: state.getIn(['suggestions', 'items']),
|
||||
searchTerm: state.getIn(['search', 'searchTerm']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -4,6 +4,7 @@ import NavigationContainer from './containers/navigation_container';
|
|||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import SearchContainer from './containers/search_container';
|
||||
|
@ -27,9 +28,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
onClickElefriend () {
|
||||
dispatch(cycleElefriendCompose());
|
||||
},
|
||||
|
||||
onMount () {
|
||||
dispatch(mountCompose());
|
||||
},
|
||||
|
||||
onUnmount () {
|
||||
dispatch(unmountCompose());
|
||||
},
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class Compose extends React.PureComponent {
|
||||
static propTypes = {
|
||||
|
@ -38,9 +47,27 @@ class Compose extends React.PureComponent {
|
|||
isSearchPage: PropTypes.bool,
|
||||
elefriend: PropTypes.number,
|
||||
onClickElefriend: PropTypes.func,
|
||||
onMount: PropTypes.func,
|
||||
onUnmount: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { isSearchPage } = this.props;
|
||||
|
||||
if (!isSearchPage) {
|
||||
this.props.onMount();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { isSearchPage } = this.props;
|
||||
|
||||
if (!isSearchPage) {
|
||||
this.props.onUnmount();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
elefriend,
|
||||
|
@ -61,12 +88,12 @@ class Compose extends React.PureComponent {
|
|||
<div className='drawer__pager'>
|
||||
{!isSearchPage && <div className='drawer__inner'>
|
||||
<NavigationContainer />
|
||||
|
||||
<ComposeFormContainer />
|
||||
{multiColumn && (
|
||||
<div className='drawer__inner__mastodon'>
|
||||
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='drawer__inner__mastodon'>
|
||||
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
|
||||
export default class Conversation extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
conversationId: PropTypes.string.isRequired,
|
||||
accounts: ImmutablePropTypes.list.isRequired,
|
||||
lastStatusId: PropTypes.string,
|
||||
unread:PropTypes.bool.isRequired,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
markRead: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lastStatusId, unread, markRead } = this.props;
|
||||
|
||||
if (unread) {
|
||||
markRead();
|
||||
}
|
||||
|
||||
this.context.router.history.push(`/statuses/${lastStatusId}`);
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.props.onMoveUp(this.props.conversationId);
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.props.onMoveDown(this.props.conversationId);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accounts, lastStatusId, unread } = this.props;
|
||||
|
||||
if (lastStatusId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
id={lastStatusId}
|
||||
unread={unread}
|
||||
otherAccounts={accounts}
|
||||
onMoveUp={this.handleHotkeyMoveUp}
|
||||
onMoveDown={this.handleHotkeyMoveDown}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ConversationContainer from '../containers/conversation_container';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export default class ConversationsList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
conversations: ImmutablePropTypes.list.isRequired,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
onLoadMore: PropTypes.func,
|
||||
};
|
||||
|
||||
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.getCurrentIndex(id) - 1;
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.getCurrentIndex(id) + 1;
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const last = this.props.conversations.last();
|
||||
|
||||
if (last && last.get('last_status')) {
|
||||
this.props.onLoadMore(last.get('last_status'));
|
||||
}
|
||||
}, 300, { leading: true })
|
||||
|
||||
render () {
|
||||
const { conversations, onLoadMore, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
|
||||
{conversations.map(item => (
|
||||
<ConversationContainer
|
||||
key={item.get('id')}
|
||||
conversationId={item.get('id')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Conversation from '../components/conversation';
|
||||
import { markConversationRead } from '../../../actions/conversations';
|
||||
|
||||
const mapStateToProps = (state, { conversationId }) => {
|
||||
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
|
||||
|
||||
return {
|
||||
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
||||
unread: conversation.get('unread'),
|
||||
lastStatusId: conversation.get('last_status', null),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { conversationId }) => ({
|
||||
markRead: () => dispatch(markConversationRead(conversationId)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
|
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ConversationsList from '../components/conversations_list';
|
||||
import { expandConversations } from 'flavours/glitch/actions/conversations';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
conversations: state.getIn(['conversations', 'items']),
|
||||
isLoading: state.getIn(['conversations', 'isLoading'], true),
|
||||
hasMore: state.getIn(['conversations', 'hasMore'], false),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onLoadMore: maxId => dispatch(expandConversations({ maxId })),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
|
|
@ -5,10 +5,13 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l
|
|||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
|
||||
import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
|
||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { connectDirectStream } from 'flavours/glitch/actions/streaming';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import ConversationsListContainer from './containers/conversations_list_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||
|
@ -16,6 +19,7 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
|
||||
conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
|
@ -28,6 +32,7 @@ export default class DirectTimeline extends React.PureComponent {
|
|||
intl: PropTypes.object.isRequired,
|
||||
hasUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
conversationsMode: PropTypes.bool,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
|
@ -50,13 +55,32 @@ export default class DirectTimeline extends React.PureComponent {
|
|||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, conversationsMode } = this.props;
|
||||
|
||||
dispatch(mountConversations());
|
||||
|
||||
if (conversationsMode) {
|
||||
dispatch(expandConversations());
|
||||
} else {
|
||||
dispatch(expandDirectTimeline());
|
||||
}
|
||||
|
||||
dispatch(expandDirectTimeline());
|
||||
this.disconnect = dispatch(connectDirectStream());
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { dispatch, conversationsMode } = this.props;
|
||||
|
||||
if (prevProps.conversationsMode && !conversationsMode) {
|
||||
dispatch(expandDirectTimeline());
|
||||
} else if (!prevProps.conversationsMode && conversationsMode) {
|
||||
dispatch(expandConversations());
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.dispatch(unmountConversations());
|
||||
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
this.disconnect = null;
|
||||
|
@ -67,14 +91,49 @@ export default class DirectTimeline extends React.PureComponent {
|
|||
this.column = c;
|
||||
}
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
handleLoadMoreTimeline = maxId => {
|
||||
this.props.dispatch(expandDirectTimeline({ maxId }));
|
||||
}
|
||||
|
||||
handleLoadMoreConversations = maxId => {
|
||||
this.props.dispatch(expandConversations({ maxId }));
|
||||
}
|
||||
|
||||
handleTimelineClick = () => {
|
||||
this.props.dispatch(changeSetting(['direct', 'conversations'], false));
|
||||
}
|
||||
|
||||
handleConversationsClick = () => {
|
||||
this.props.dispatch(changeSetting(['direct', 'conversations'], true));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, hasUnread, columnId, multiColumn } = this.props;
|
||||
const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
let contents;
|
||||
if (conversationsMode) {
|
||||
contents = (
|
||||
<ConversationsListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`direct_timeline-${columnId}`}
|
||||
timelineId='direct'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
contents = (
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`direct_timeline-${columnId}`}
|
||||
timelineId='direct'
|
||||
onLoadMore={this.handleLoadMoreTimeline}
|
||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
|
@ -90,13 +149,28 @@ export default class DirectTimeline extends React.PureComponent {
|
|||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`direct_timeline-${columnId}`}
|
||||
timelineId='direct'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||
/>
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={conversationsMode ? 'active' : ''}
|
||||
onClick={this.handleConversationsClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='direct.conversations_mode'
|
||||
defaultMessage='Conversations'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={conversationsMode ? '' : 'active'}
|
||||
onClick={this.handleTimelineClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='direct.timeline_mode'
|
||||
defaultMessage='Timeline'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contents}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import Overlay from 'react-overlays/lib/Overlay';
|
|||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
|
||||
import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
|
@ -110,19 +110,6 @@ let EmojiPicker, Emoji; // load asynchronously
|
|||
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'custom',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -320,8 +307,23 @@ class EmojiPickerMenu extends React.PureComponent {
|
|||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
const { modifierOpen } = this.state;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<EmojiPicker
|
||||
|
|
|
@ -59,7 +59,7 @@ export default class FollowRequests extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}>
|
||||
<Column name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
|
||||
<ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
|
|
|
@ -8,12 +8,14 @@ import { openModal } from 'flavours/glitch/actions/modal';
|
|||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me, invitesEnabled, version } from 'flavours/glitch/util/initial_state';
|
||||
import { me } from 'flavours/glitch/util/initial_state';
|
||||
import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchLists } from 'flavours/glitch/actions/lists';
|
||||
import { preferencesLink, profileLink, signOutLink } from 'flavours/glitch/util/backend_links';
|
||||
import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links';
|
||||
import NavigationBar from '../compose/components/navigation_bar';
|
||||
import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
|
@ -73,9 +75,15 @@ const badgeDisplay = (number, limit) => {
|
|||
}
|
||||
};
|
||||
|
||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
export default class GettingStarted extends ImmutablePureComponent {
|
||||
const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
|
||||
|
||||
export default @connect(makeMapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -95,7 +103,12 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { myAccount, fetchFollowRequests } = this.props;
|
||||
const { myAccount, fetchFollowRequests, multiColumn } = this.props;
|
||||
|
||||
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
|
||||
this.context.router.history.replace('/timelines/home');
|
||||
return;
|
||||
}
|
||||
|
||||
if (myAccount.get('locked')) {
|
||||
fetchFollowRequests();
|
||||
|
@ -135,7 +148,7 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (myAccount.get('locked')) {
|
||||
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
}
|
||||
|
||||
navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
|
||||
|
@ -153,7 +166,8 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
<Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
|
||||
<div className='scrollable optionally-scrollable'>
|
||||
<div className='getting-started__wrapper'>
|
||||
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
|
||||
{!multiColumn && <NavigationBar account={myAccount} />}
|
||||
{multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />}
|
||||
{navItems}
|
||||
<ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
|
||||
{listItems}
|
||||
|
@ -163,25 +177,7 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' />
|
||||
</div>
|
||||
|
||||
<div className='getting-started__footer'>
|
||||
<ul>
|
||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
|
||||
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
|
||||
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
|
||||
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='getting_started.open_source_notice'
|
||||
defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
|
||||
values={{
|
||||
github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
|
||||
Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<LinkFooter />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -71,6 +71,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><kbd>x</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>h</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
||||
</tr>
|
||||
{collapseEnabled && (
|
||||
<tr>
|
||||
<td><kbd>shift</kbd>+<kbd>x</kbd></td>
|
||||
|
|
|
@ -75,6 +75,23 @@ export default class ListTimeline extends React.PureComponent {
|
|||
this.disconnect = dispatch(connectListStream(id));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = nextProps.params;
|
||||
|
||||
if (id !== this.props.params.id) {
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
this.disconnect = null;
|
||||
}
|
||||
|
||||
dispatch(fetchList(id));
|
||||
dispatch(expandListTimeline(id));
|
||||
|
||||
this.disconnect = dispatch(connectListStream(id));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
|
|
|
@ -13,6 +13,7 @@ const messages = defineMessages({
|
|||
general: { id: 'settings.general', defaultMessage: 'General' },
|
||||
compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box' },
|
||||
content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' },
|
||||
filters: { id: 'settings.filters', defaultMessage: 'Filters' },
|
||||
collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
|
||||
media: { id: 'settings.media', defaultMessage: 'Media' },
|
||||
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
|
||||
|
@ -60,27 +61,34 @@ export default class LocalSettingsNavigation extends React.PureComponent {
|
|||
active={index === 3}
|
||||
index={3}
|
||||
onNavigate={onNavigate}
|
||||
icon='angle-double-up'
|
||||
title={intl.formatMessage(messages.collapsed)}
|
||||
icon='filter'
|
||||
title={intl.formatMessage(messages.filters)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 4}
|
||||
index={4}
|
||||
onNavigate={onNavigate}
|
||||
icon='angle-double-up'
|
||||
title={intl.formatMessage(messages.collapsed)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 5}
|
||||
index={5}
|
||||
onNavigate={onNavigate}
|
||||
icon='image'
|
||||
title={intl.formatMessage(messages.media)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 5}
|
||||
active={index === 6}
|
||||
href={ preferencesLink }
|
||||
index={5}
|
||||
icon='sliders'
|
||||
index={6}
|
||||
icon='cog'
|
||||
title={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 6}
|
||||
active={index === 7}
|
||||
className='close'
|
||||
index={6}
|
||||
index={7}
|
||||
onNavigate={onClose}
|
||||
icon='times'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue