Merge branch 'master' of https://github.com/glitch-soc/mastodon
commit
d606873fef
|
@ -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"
|
|
@ -10,6 +10,7 @@ DB_NAME=postgres
|
|||
DB_PASS=
|
||||
DB_PORT=5432
|
||||
# Optional ElasticSearch configuration
|
||||
# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set)
|
||||
# ES_ENABLED=true
|
||||
# ES_HOST=es
|
||||
# ES_PORT=9200
|
||||
|
@ -156,6 +157,24 @@ STREAMING_CLUSTER_NUM=1
|
|||
# Maximum allowed character count
|
||||
# MAX_TOOT_CHARS=500
|
||||
|
||||
# Maximum number of pinned posts
|
||||
# MAX_PINNED_TOOTS=5
|
||||
|
||||
# Maximum allowed bio characters
|
||||
# MAX_BIO_CHARS=500
|
||||
|
||||
# Maximim number of profile fields allowed
|
||||
# MAX_PROFILE_FIELDS=4
|
||||
|
||||
# Maximum allowed display name characters
|
||||
# MAX_DISPLAY_NAME_CHARS=30
|
||||
|
||||
# Maximum image and video/audio upload sizes
|
||||
# Units are in bytes
|
||||
# 1048576 bytes equals 1 megabyte
|
||||
# MAX_IMAGE_SIZE=8388608
|
||||
# MAX_VIDEO_SIZE=41943040
|
||||
|
||||
# LDAP authentication (optional)
|
||||
# LDAP_ENABLED=true
|
||||
# LDAP_HOST=localhost
|
||||
|
|
|
@ -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
|
||||
|
|
177
CHANGELOG.md
177
CHANGELOG.md
|
@ -3,6 +3,183 @@ Changelog
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.9.2] - 2019-06-22
|
||||
### Added
|
||||
|
||||
- Add `short_description` and `approval_required` to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/11146))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change camera icon to paperclip icon in upload form ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11149))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix audio-only OGG and WebM files not being processed as such ([Gargron](https://github.com/tootsuite/mastodon/pull/11151))
|
||||
- Fix audio not being downloaded from remote servers ([Gargron](https://github.com/tootsuite/mastodon/pull/11145))
|
||||
|
||||
## [2.9.1] - 2019-06-22
|
||||
### Added
|
||||
|
||||
- Add moderation API ([Gargron](https://github.com/tootsuite/mastodon/pull/9387))
|
||||
- Add audio uploads ([Gargron](https://github.com/tootsuite/mastodon/pull/11123), [Gargron](https://github.com/tootsuite/mastodon/pull/11141))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change domain blocks to automatically support subdomains ([Gargron](https://github.com/tootsuite/mastodon/pull/11138))
|
||||
- Change Nanobox configuration to bring it up to date ([danhunsaker](https://github.com/tootsuite/mastodon/pull/11083))
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove expensive counters from federation page in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11139))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix converted media being saved with original extension and mime type ([Gargron](https://github.com/tootsuite/mastodon/pull/11130))
|
||||
- Fix layout of identity proofs settings ([acid-chicken](https://github.com/tootsuite/mastodon/pull/11126))
|
||||
- Fix active scope only returning suspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/11111))
|
||||
- Fix sanitizer making block level elements unreadable ([Gargron](https://github.com/tootsuite/mastodon/pull/10836))
|
||||
- Fix label for site theme not being translated in admin UI ([palindromordnilap](https://github.com/tootsuite/mastodon/pull/11121))
|
||||
- Fix statuses not being filtered irreversibly in web UI under some circumstances ([ThibG](https://github.com/tootsuite/mastodon/pull/11113))
|
||||
- Fix scrolling behaviour in compose form ([ThibG](https://github.com/tootsuite/mastodon/pull/11093))
|
||||
|
||||
## [2.9.0] - 2019-06-13
|
||||
### Added
|
||||
|
||||
- **Add single-column mode in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10807), [Gargron](https://github.com/tootsuite/mastodon/pull/10848), [Gargron](https://github.com/tootsuite/mastodon/pull/11003), [Gargron](https://github.com/tootsuite/mastodon/pull/10961), [Hanage999](https://github.com/tootsuite/mastodon/pull/10915), [noellabo](https://github.com/tootsuite/mastodon/pull/10917), [abcang](https://github.com/tootsuite/mastodon/pull/10859), [Gargron](https://github.com/tootsuite/mastodon/pull/10820), [Gargron](https://github.com/tootsuite/mastodon/pull/10835), [Gargron](https://github.com/tootsuite/mastodon/pull/10809), [Gargron](https://github.com/tootsuite/mastodon/pull/10963), [noellabo](https://github.com/tootsuite/mastodon/pull/10883), [Hanage999](https://github.com/tootsuite/mastodon/pull/10839))
|
||||
- Add waiting time to the list of pending accounts in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10985))
|
||||
- Add a keyboard shortcut to hide/show media in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10647), [Gargron](https://github.com/tootsuite/mastodon/pull/10838), [ThibG](https://github.com/tootsuite/mastodon/pull/10872))
|
||||
- Add `account_id` param to `GET /api/v1/notifications` ([pwoolcoc](https://github.com/tootsuite/mastodon/pull/10796))
|
||||
- Add confirmation modal for unboosting toots in web UI ([aurelien-reeves](https://github.com/tootsuite/mastodon/pull/10287))
|
||||
- Add emoji suggestions to content warning and poll option fields in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10555))
|
||||
- Add `source` attribute to response of `DELETE /api/v1/statuses/:id` ([ThibG](https://github.com/tootsuite/mastodon/pull/10669))
|
||||
- Add some caching for HTML versions of public status pages ([ThibG](https://github.com/tootsuite/mastodon/pull/10701))
|
||||
- Add button to conveniently copy OAuth code ([ThibG](https://github.com/tootsuite/mastodon/pull/11065))
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change default layout to single column in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10847))
|
||||
- **Change light theme** ([Gargron](https://github.com/tootsuite/mastodon/pull/10992), [Gargron](https://github.com/tootsuite/mastodon/pull/10996), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10754), [Gargron](https://github.com/tootsuite/mastodon/pull/10845))
|
||||
- **Change preferences page into appearance, notifications, and other** ([Gargron](https://github.com/tootsuite/mastodon/pull/10977), [Gargron](https://github.com/tootsuite/mastodon/pull/10988))
|
||||
- Change priority of delete activity forwards for replies and reblogs ([Gargron](https://github.com/tootsuite/mastodon/pull/11002))
|
||||
- Change Mastodon logo to use primary text color of the given theme ([Gargron](https://github.com/tootsuite/mastodon/pull/10994))
|
||||
- Change reblogs counter to be updated when boosted privately ([Gargron](https://github.com/tootsuite/mastodon/pull/10964))
|
||||
- Change bio limit from 160 to 500 characters ([trwnh](https://github.com/tootsuite/mastodon/pull/10790))
|
||||
- Change API rate limiting to reduce allowed unauthenticated requests ([ThibG](https://github.com/tootsuite/mastodon/pull/10860), [hinaloe](https://github.com/tootsuite/mastodon/pull/10868), [mayaeh](https://github.com/tootsuite/mastodon/pull/10867))
|
||||
- Change help text of `tootctl emoji import` command to specify a gzipped TAR archive is required ([dariusk](https://github.com/tootsuite/mastodon/pull/11000))
|
||||
- Change web UI to hide poll options behind content warnings ([ThibG](https://github.com/tootsuite/mastodon/pull/10983))
|
||||
- Change silencing to ensure local effects and remote effects are the same for silenced local users ([ThibG](https://github.com/tootsuite/mastodon/pull/10575))
|
||||
- Change `tootctl domains purge` to remove custom emoji as well ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10721))
|
||||
- Change Docker image to keep `apt` working ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10830))
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove `dist-upgrade` from Docker image ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10822))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix RTL layout not being RTL within the columns area in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10990))
|
||||
- Fix display of alternative text when a media attachment is not available in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10981))
|
||||
- Fix not being able to directly switch between list timelines in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10973))
|
||||
- Fix media sensitivity not being maintained in delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10980))
|
||||
- Fix emoji picker being always displayed in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/10979), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10801), [wcpaez](https://github.com/tootsuite/mastodon/pull/10978))
|
||||
- Fix potential private status leak through caching ([ThibG](https://github.com/tootsuite/mastodon/pull/10969))
|
||||
- Fix refreshing featured toots when the new collection is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10971))
|
||||
- Fix undoing domain block also undoing individual moderation on users from before the domain block ([ThibG](https://github.com/tootsuite/mastodon/pull/10660))
|
||||
- Fix time not being local in the audit log ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10751))
|
||||
- Fix statuses removed by moderation re-appearing on subsequent fetches ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10732))
|
||||
- Fix misattribution of inlined announces if `attributedTo` isn't present in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10967))
|
||||
- Fix `GET /api/v1/polls/:id` not requiring authentication for non-public polls ([Gargron](https://github.com/tootsuite/mastodon/pull/10960))
|
||||
- Fix handling of blank poll options in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10946))
|
||||
- Fix avatar preview aspect ratio on edit profile page ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10931))
|
||||
- Fix web push notifications not being sent for polls ([ThibG](https://github.com/tootsuite/mastodon/pull/10864))
|
||||
- Fix cut off letters in last paragraph of statuses in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/10821))
|
||||
- Fix list not being automatically unpinned when it returns 404 in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11045))
|
||||
- Fix login sometimes redirecting to paths that are not pages ([Gargron](https://github.com/tootsuite/mastodon/pull/11019))
|
||||
|
||||
## [2.8.4] - 2019-05-24
|
||||
### Fixed
|
||||
|
||||
- Fix delivery not retrying on some inbox errors that should be retriable ([ThibG](https://github.com/tootsuite/mastodon/pull/10812))
|
||||
- Fix unnecessary 5 minute cooldowns on signature verifications in some cases ([ThibG](https://github.com/tootsuite/mastodon/pull/10813))
|
||||
- Fix possible race condition when processing statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10815))
|
||||
|
||||
### Security
|
||||
|
||||
- Require specific OAuth scopes for specific endpoints of the streaming API, instead of merely requiring a token for all endpoints, and allow using WebSockets protocol negotiation to specify the access token instead of using a query string ([ThibG](https://github.com/tootsuite/mastodon/pull/10818))
|
||||
|
||||
## [2.8.3] - 2019-05-19
|
||||
### Added
|
||||
|
||||
- Add `og:image:alt` OpenGraph tag ([BenLubar](https://github.com/tootsuite/mastodon/pull/10779))
|
||||
- Add clickable area below avatar in statuses in web UI ([Dar13](https://github.com/tootsuite/mastodon/pull/10766))
|
||||
- Add crossed-out eye icon on account gallery in web UI ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10715))
|
||||
- Add media description tooltip to thumbnails in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10713))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change "mark as sensitive" button into a checkbox for clarity ([ThibG](https://github.com/tootsuite/mastodon/pull/10748))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix bug allowing users to publicly boost their private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10775), [ThibG](https://github.com/tootsuite/mastodon/pull/10783))
|
||||
- Fix performance in formatter by a little ([ThibG](https://github.com/tootsuite/mastodon/pull/10765))
|
||||
- Fix some colors in the light theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10754))
|
||||
- Fix some colors of the high contrast theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10711))
|
||||
- Fix ambivalent active state of poll refresh button in web UI ([MaciekBaron](https://github.com/tootsuite/mastodon/pull/10720))
|
||||
- Fix duplicate posting being possible from web UI ([hinaloe](https://github.com/tootsuite/mastodon/pull/10785))
|
||||
- Fix "invited by" not showing up in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10791))
|
||||
|
||||
## [2.8.2] - 2019-05-05
|
||||
### Added
|
||||
|
||||
- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/tootsuite/mastodon/pull/10698))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/tootsuite/mastodon/pull/10702))
|
||||
- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/tootsuite/mastodon/pull/10700))
|
||||
- Fix unexpected CSS animations in some browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/10699))
|
||||
- Fix closing video modal scrolling timelines to top ([ThibG](https://github.com/tootsuite/mastodon/pull/10695))
|
||||
|
||||
## [2.8.1] - 2019-05-04
|
||||
### Added
|
||||
|
||||
- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
|
||||
- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
|
||||
- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
|
||||
- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
|
||||
- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
|
||||
- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||
- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||
- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
|
||||
- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
|
||||
- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
|
||||
- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
|
||||
- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
|
||||
- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
|
||||
- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
|
||||
- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
|
||||
- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
|
||||
- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
|
||||
- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
|
||||
- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
|
||||
- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
|
||||
- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
|
||||
- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
|
||||
- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
|
||||
- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
|
||||
- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
|
||||
- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
|
||||
- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
|
||||
|
||||
## [2.8.0] - 2019-04-10
|
||||
### Added
|
||||
|
||||
|
|
|
@ -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,9 +52,9 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://
|
|||
|
||||
## Translations
|
||||
|
||||
You can submit translations via [Weblate](https://weblate.joinmastodon.org/). They are periodically merged into the codebase.
|
||||
You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase.
|
||||
|
||||
[![Mastodon translation statistics by language](https://weblate.joinmastodon.org/widgets/mastodon/-/multi-auto.svg)](https://weblate.joinmastodon.org/)
|
||||
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
|
||||
|
||||
## Pull requests
|
||||
|
||||
|
|
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"
|
||||
|
||||
|
|
35
Gemfile
35
Gemfile
|
@ -15,12 +15,13 @@ gem 'makara', '~> 0.4'
|
|||
gem 'pghero', '~> 2.2'
|
||||
gem 'dotenv-rails', '~> 2.7'
|
||||
|
||||
gem 'aws-sdk-s3', '~> 1.36', require: false
|
||||
gem 'aws-sdk-s3', '~> 1.42', require: false
|
||||
gem 'fog-core', '<= 2.1.0'
|
||||
gem 'fog-openstack', '~> 0.3', require: false
|
||||
gem 'paperclip', '~> 6.0'
|
||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||
gem 'streamio-ffmpeg', '~> 3.0'
|
||||
gem 'blurhash', '~> 0.1'
|
||||
|
||||
gem 'active_model_serializers', '~> 0.10'
|
||||
gem 'addressable', '~> 2.6'
|
||||
|
@ -29,7 +30,7 @@ gem 'browser'
|
|||
gem 'charlock_holmes', '~> 0.7.6'
|
||||
gem 'iso-639'
|
||||
gem 'chewy', '~> 5.0'
|
||||
gem 'cld3', '~> 3.2.3'
|
||||
gem 'cld3', '~> 3.2.4'
|
||||
gem 'devise', '~> 4.6'
|
||||
gem 'devise-two-factor', '~> 3.0'
|
||||
|
||||
|
@ -42,7 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
|
|||
gem 'omniauth-saml', '~> 1.10'
|
||||
gem 'omniauth', '~> 1.9'
|
||||
|
||||
gem 'doorkeeper', '~> 5.0'
|
||||
gem 'doorkeeper', '~> 5.1'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
gem 'goldfinger', '~> 2.1'
|
||||
|
@ -53,7 +54,7 @@ gem 'htmlentities', '~> 4.3'
|
|||
gem 'http', '~> 3.3'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2'
|
||||
gem 'httplog', '~> 1.2'
|
||||
gem 'httplog', '~> 1.3'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'kaminari', '~> 1.1'
|
||||
gem 'link_header', '~> 0.0'
|
||||
|
@ -62,11 +63,11 @@ gem 'nokogiri', '~> 1.10'
|
|||
gem 'nsa', '~> 0.2'
|
||||
gem 'oj', '~> 3.7'
|
||||
gem 'ostatus2', '~> 2.0'
|
||||
gem 'ox', '~> 2.10'
|
||||
gem 'ox', '~> 2.11'
|
||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
||||
gem 'pundit', '~> 2.0'
|
||||
gem 'premailer-rails'
|
||||
gem 'rack-attack', '~> 5.4'
|
||||
gem 'rack-attack', '~> 6.0'
|
||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
||||
gem 'rails-i18n', '~> 5.1'
|
||||
gem 'rails-settings-cached', '~> 0.6'
|
||||
|
@ -82,9 +83,9 @@ gem 'simple-navigation', '~> 4.0'
|
|||
gem 'simple_form', '~> 4.1'
|
||||
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
||||
gem 'stoplight', '~> 2.1.3'
|
||||
gem 'strong_migrations', '~> 0.3'
|
||||
gem 'strong_migrations', '~> 0.4'
|
||||
gem 'tty-command', '~> 0.8', require: false
|
||||
gem 'tty-prompt', '~> 0.18', require: false
|
||||
gem 'tty-prompt', '~> 0.19', require: false
|
||||
gem 'twitter-text', '~> 1.14'
|
||||
gem 'tzinfo-data', '~> 1.2019'
|
||||
gem 'webpacker', '~> 4.0'
|
||||
|
@ -94,9 +95,11 @@ gem 'json-ld', '~> 3.0'
|
|||
gem 'json-ld-preloaded', '~> 3.0'
|
||||
gem 'rdf-normalize', '~> 0.3'
|
||||
|
||||
gem 'redcarpet', '~> 3.4'
|
||||
|
||||
group :development, :test do
|
||||
gem 'fabrication', '~> 2.20'
|
||||
gem 'fuubar', '~> 2.3'
|
||||
gem 'fuubar', '~> 2.4'
|
||||
gem 'i18n-tasks', '~> 0.9', require: false
|
||||
gem 'pry-byebug', '~> 3.7'
|
||||
gem 'pry-rails', '~> 0.3'
|
||||
|
@ -108,15 +111,15 @@ group :production, :test do
|
|||
end
|
||||
|
||||
group :test do
|
||||
gem 'capybara', '~> 3.16'
|
||||
gem 'capybara', '~> 3.24'
|
||||
gem 'climate_control', '~> 0.2'
|
||||
gem 'faker', '~> 1.9'
|
||||
gem 'microformats', '~> 4.1'
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
gem 'rspec-sidekiq', '~> 3.0'
|
||||
gem 'simplecov', '~> 0.16', require: false
|
||||
gem 'webmock', '~> 3.5'
|
||||
gem 'parallel_tests', '~> 2.28'
|
||||
gem 'webmock', '~> 3.6'
|
||||
gem 'parallel_tests', '~> 2.29'
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
@ -124,14 +127,14 @@ group :development do
|
|||
gem 'annotate', '~> 2.7'
|
||||
gem 'better_errors', '~> 2.5'
|
||||
gem 'binding_of_caller', '~> 0.7'
|
||||
gem 'bullet', '~> 5.9'
|
||||
gem 'bullet', '~> 6.0'
|
||||
gem 'letter_opener', '~> 1.7'
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', '~> 0.67', require: false
|
||||
gem 'rubocop', '~> 0.71', require: false
|
||||
gem 'rubocop-rails', '~> 2.0', require: false
|
||||
gem 'brakeman', '~> 4.5', require: false
|
||||
gem 'bundler-audit', '~> 0.6', require: false
|
||||
gem 'scss_lint', '~> 0.57', require: false
|
||||
|
||||
gem 'capistrano', '~> 3.11'
|
||||
gem 'capistrano-rails', '~> 1.4'
|
||||
|
@ -143,7 +146,7 @@ group :development do
|
|||
end
|
||||
|
||||
group :production do
|
||||
gem 'lograge', '~> 0.10'
|
||||
gem 'lograge', '~> 0.11'
|
||||
gem 'redis-rails', '~> 5.0'
|
||||
end
|
||||
|
||||
|
|
174
Gemfile.lock
174
Gemfile.lock
|
@ -66,8 +66,8 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 4.0)
|
||||
airbrussh (1.3.0)
|
||||
sshkit (>= 1.6.1, != 1.7.0)
|
||||
annotate (2.7.4)
|
||||
activerecord (>= 3.2, < 6.0)
|
||||
annotate (2.7.5)
|
||||
activerecord (>= 3.2, < 7.0)
|
||||
rake (>= 10.4, < 13.0)
|
||||
arel (9.0.0)
|
||||
ast (2.4.0)
|
||||
|
@ -75,20 +75,20 @@ GEM
|
|||
encryptor (~> 3.0.0)
|
||||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-eventstream (1.0.2)
|
||||
aws-partitions (1.147.0)
|
||||
aws-sdk-core (3.48.3)
|
||||
aws-eventstream (1.0.3)
|
||||
aws-partitions (1.175.0)
|
||||
aws-sdk-core (3.55.0)
|
||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||
aws-partitions (~> 1.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.16.0)
|
||||
aws-sdk-core (~> 3, >= 3.48.2)
|
||||
aws-sdk-kms (1.21.0)
|
||||
aws-sdk-core (~> 3, >= 3.53.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.36.0)
|
||||
aws-sdk-core (~> 3, >= 3.48.2)
|
||||
aws-sdk-s3 (1.42.0)
|
||||
aws-sdk-core (~> 3, >= 3.53.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.1.0)
|
||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||
bcrypt (3.1.12)
|
||||
|
@ -99,12 +99,14 @@ GEM
|
|||
rack (>= 0.9.0)
|
||||
binding_of_caller (0.8.0)
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (1.4.3)
|
||||
blurhash (0.1.3)
|
||||
ffi (~> 1.10.0)
|
||||
bootsnap (1.4.4)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (4.5.0)
|
||||
brakeman (4.5.1)
|
||||
browser (2.5.3)
|
||||
builder (3.2.3)
|
||||
bullet (5.9.0)
|
||||
bullet (6.0.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.6.1)
|
||||
|
@ -127,13 +129,13 @@ GEM
|
|||
sshkit (~> 1.3)
|
||||
capistrano-yarn (2.0.2)
|
||||
capistrano (~> 3.0)
|
||||
capybara (3.16.1)
|
||||
capybara (3.24.0)
|
||||
addressable
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.8)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (~> 1.2)
|
||||
regexp_parser (~> 1.5)
|
||||
xpath (~> 3.2)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
|
@ -143,8 +145,8 @@ GEM
|
|||
elasticsearch (>= 2.0.0)
|
||||
elasticsearch-dsl
|
||||
chunky_png (1.3.10)
|
||||
cld3 (3.2.3)
|
||||
ffi (>= 1.1.0, < 1.10.0)
|
||||
cld3 (3.2.4)
|
||||
ffi (>= 1.1.0, < 1.11.0)
|
||||
climate_control (0.2.0)
|
||||
cocaine (0.5.8)
|
||||
climate_control (>= 0.0.3, < 1.0)
|
||||
|
@ -184,8 +186,8 @@ GEM
|
|||
docile (1.3.0)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (5.0.2)
|
||||
railties (>= 4.2)
|
||||
doorkeeper (5.1.0)
|
||||
railties (>= 5)
|
||||
dotenv (2.7.2)
|
||||
dotenv-rails (2.7.2)
|
||||
dotenv (= 2.7.2)
|
||||
|
@ -205,14 +207,14 @@ GEM
|
|||
et-orbi (1.1.6)
|
||||
tzinfo
|
||||
excon (0.62.0)
|
||||
fabrication (2.20.1)
|
||||
fabrication (2.20.2)
|
||||
faker (1.9.3)
|
||||
i18n (>= 0.7)
|
||||
faraday (0.15.0)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
fast_blank (1.0.0)
|
||||
fastimage (2.1.5)
|
||||
ffi (1.9.25)
|
||||
ffi (1.10.0)
|
||||
fog-core (2.1.0)
|
||||
builder
|
||||
excon (~> 0.58)
|
||||
|
@ -229,7 +231,7 @@ GEM
|
|||
fugit (1.1.6)
|
||||
et-orbi (~> 1.1, >= 1.1.6)
|
||||
raabro (~> 1.1)
|
||||
fuubar (2.3.2)
|
||||
fuubar (2.4.0)
|
||||
rspec-core (~> 3.0)
|
||||
ruby-progressbar (~> 1.4)
|
||||
get_process_mem (0.2.3)
|
||||
|
@ -251,7 +253,7 @@ GEM
|
|||
railties (>= 4.0.1)
|
||||
hamster (3.0.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
hashdiff (0.3.7)
|
||||
hashdiff (0.4.0)
|
||||
hashie (3.6.0)
|
||||
heapy (0.1.4)
|
||||
highline (2.0.1)
|
||||
|
@ -269,7 +271,7 @@ GEM
|
|||
domain_name (~> 0.5)
|
||||
http-form_data (2.1.1)
|
||||
http_accept_language (2.1.1)
|
||||
httplog (1.2.2)
|
||||
httplog (1.3.1)
|
||||
rack (>= 1.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.6.0)
|
||||
|
@ -320,7 +322,7 @@ GEM
|
|||
letter_opener (~> 1.0)
|
||||
railties (>= 3.2)
|
||||
link_header (0.0.8)
|
||||
lograge (0.10.0)
|
||||
lograge (0.11.2)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
|
@ -348,16 +350,16 @@ GEM
|
|||
mini_mime (1.0.1)
|
||||
mini_portile2 (2.4.0)
|
||||
minitest (5.11.3)
|
||||
msgpack (1.2.9)
|
||||
msgpack (1.2.10)
|
||||
multi_json (1.13.1)
|
||||
multipart-post (2.0.0)
|
||||
necromancer (0.4.0)
|
||||
necromancer (0.5.0)
|
||||
net-ldap (0.16.1)
|
||||
net-scp (1.2.1)
|
||||
net-ssh (>= 2.6.5)
|
||||
net-ssh (5.0.2)
|
||||
nio4r (2.3.1)
|
||||
nokogiri (1.10.2)
|
||||
nokogiri (1.10.3)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
nokogumbo (2.0.0)
|
||||
nokogiri (~> 1.8, >= 1.8.4)
|
||||
|
@ -366,7 +368,7 @@ GEM
|
|||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sidekiq (>= 3.5)
|
||||
statsd-ruby (~> 1.4, >= 1.4.0)
|
||||
oj (3.7.11)
|
||||
oj (3.7.12)
|
||||
omniauth (1.9.0)
|
||||
hashie (>= 3.4.6, < 3.7.0)
|
||||
rack (>= 1.6.2, < 3)
|
||||
|
@ -382,7 +384,7 @@ GEM
|
|||
addressable (~> 2.5)
|
||||
http (~> 3.0)
|
||||
nokogiri (~> 1.8)
|
||||
ox (2.10.0)
|
||||
ox (2.11.0)
|
||||
paperclip (6.0.0)
|
||||
activemodel (>= 4.2.0)
|
||||
activesupport (>= 4.2.0)
|
||||
|
@ -393,15 +395,15 @@ GEM
|
|||
av (~> 0.9.0)
|
||||
paperclip (>= 2.5.2)
|
||||
parallel (1.17.0)
|
||||
parallel_tests (2.28.0)
|
||||
parallel_tests (2.29.0)
|
||||
parallel
|
||||
parser (2.6.2.0)
|
||||
parser (2.6.3.0)
|
||||
ast (~> 2.4.0)
|
||||
pastel (0.7.2)
|
||||
equatable (~> 0.5.0)
|
||||
tty-color (~> 0.4.0)
|
||||
pg (1.1.4)
|
||||
pghero (2.2.0)
|
||||
pghero (2.2.1)
|
||||
activerecord
|
||||
pkg-config (1.3.7)
|
||||
premailer (1.11.1)
|
||||
|
@ -420,14 +422,13 @@ GEM
|
|||
pry (~> 0.10)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
psych (3.1.0)
|
||||
public_suffix (3.0.3)
|
||||
public_suffix (3.1.0)
|
||||
puma (3.12.1)
|
||||
pundit (2.0.1)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.1.6)
|
||||
rack (2.0.7)
|
||||
rack-attack (5.4.2)
|
||||
rack-attack (6.0.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.0.3)
|
||||
rack-protection (2.0.5)
|
||||
|
@ -471,15 +472,13 @@ GEM
|
|||
thor (>= 0.19.0, < 2.0)
|
||||
rainbow (3.0.0)
|
||||
rake (12.3.2)
|
||||
rb-fsevent (0.10.3)
|
||||
rb-inotify (0.9.10)
|
||||
ffi (>= 0.5.0, < 2)
|
||||
rdf (3.0.9)
|
||||
hamster (~> 3.0)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.3.3)
|
||||
rdf (>= 2.2, < 4.0)
|
||||
redis (4.1.0)
|
||||
redcarpet (3.4.0)
|
||||
redis (4.1.2)
|
||||
redis-actionpack (5.0.2)
|
||||
actionpack (>= 4.0, < 6)
|
||||
redis-rack (>= 1, < 3)
|
||||
|
@ -498,7 +497,7 @@ GEM
|
|||
redis-store (>= 1.2, < 2)
|
||||
redis-store (1.5.0)
|
||||
redis (>= 2.2, < 5)
|
||||
regexp_parser (1.3.0)
|
||||
regexp_parser (1.5.1)
|
||||
request_store (1.4.1)
|
||||
rack (>= 1.4)
|
||||
responders (2.4.1)
|
||||
|
@ -528,33 +527,27 @@ GEM
|
|||
rspec-core (~> 3.0, >= 3.0.0)
|
||||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.8.0)
|
||||
rubocop (0.67.1)
|
||||
rubocop (0.71.0)
|
||||
jaro_winkler (~> 1.5.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.5, != 2.5.1.1)
|
||||
psych (>= 3.1.0)
|
||||
parser (>= 2.6)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.6)
|
||||
ruby-progressbar (1.10.0)
|
||||
unicode-display_width (>= 1.4.0, < 1.7)
|
||||
rubocop-rails (2.0.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.70.0)
|
||||
ruby-progressbar (1.10.1)
|
||||
ruby-saml (1.9.0)
|
||||
nokogiri (>= 1.5.10)
|
||||
rufus-scheduler (3.5.2)
|
||||
fugit (~> 1.1, >= 1.1.5)
|
||||
safe_yaml (1.0.4)
|
||||
safe_yaml (1.0.5)
|
||||
sanitize (5.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.8.0)
|
||||
nokogumbo (~> 2.0)
|
||||
sass (3.6.0)
|
||||
sass-listen (~> 4.0.0)
|
||||
sass-listen (4.0.0)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
scss_lint (0.57.1)
|
||||
rake (>= 0.9, < 13)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
sidekiq (5.2.5)
|
||||
sidekiq (5.2.7)
|
||||
connection_pool (~> 2.2, >= 2.2.2)
|
||||
rack (>= 1.5.0)
|
||||
rack-protection (>= 1.5.0)
|
||||
|
@ -566,7 +559,7 @@ GEM
|
|||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 3)
|
||||
tilt (>= 1.4.0)
|
||||
sidekiq-unique-jobs (6.0.12)
|
||||
sidekiq-unique-jobs (6.0.13)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 4.0, < 7.0)
|
||||
thor (~> 0)
|
||||
|
@ -595,8 +588,8 @@ GEM
|
|||
stoplight (2.1.3)
|
||||
streamio-ffmpeg (3.0.2)
|
||||
multi_json (~> 1.8)
|
||||
strong_migrations (0.3.1)
|
||||
activerecord (>= 3.2.0)
|
||||
strong_migrations (0.4.0)
|
||||
activerecord (>= 5)
|
||||
temple (0.8.1)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
|
@ -605,22 +598,19 @@ GEM
|
|||
thor (0.20.3)
|
||||
thread_safe (0.3.6)
|
||||
tilt (2.0.9)
|
||||
timers (4.2.0)
|
||||
tty-color (0.4.3)
|
||||
tty-command (0.8.2)
|
||||
pastel (~> 0.7.0)
|
||||
tty-cursor (0.6.0)
|
||||
tty-prompt (0.18.1)
|
||||
necromancer (~> 0.4.0)
|
||||
tty-cursor (0.7.0)
|
||||
tty-prompt (0.19.0)
|
||||
necromancer (~> 0.5.0)
|
||||
pastel (~> 0.7.0)
|
||||
timers (~> 4.0)
|
||||
tty-cursor (~> 0.6.0)
|
||||
tty-reader (~> 0.5.0)
|
||||
tty-reader (0.5.0)
|
||||
tty-cursor (~> 0.6.0)
|
||||
tty-screen (~> 0.6.4)
|
||||
tty-reader (~> 0.6.0)
|
||||
tty-reader (0.6.0)
|
||||
tty-cursor (~> 0.7)
|
||||
tty-screen (~> 0.7)
|
||||
wisper (~> 2.0.0)
|
||||
tty-screen (0.6.5)
|
||||
tty-screen (0.7.0)
|
||||
twitter-text (1.14.7)
|
||||
unf (~> 0.1.0)
|
||||
tzinfo (1.2.5)
|
||||
|
@ -630,19 +620,19 @@ GEM
|
|||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.5)
|
||||
unicode-display_width (1.5.0)
|
||||
unicode-display_width (1.6.0)
|
||||
uniform_notifier (1.12.1)
|
||||
warden (1.2.8)
|
||||
rack (>= 2.0.6)
|
||||
webmock (3.5.1)
|
||||
webmock (3.6.0)
|
||||
addressable (>= 2.3.6)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff
|
||||
webpacker (4.0.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webpacker (4.0.7)
|
||||
activesupport (>= 4.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 4.2)
|
||||
webpush (0.3.7)
|
||||
webpush (0.3.8)
|
||||
hkdf (~> 0.2)
|
||||
jwt (~> 2.0)
|
||||
websocket-driver (0.7.0)
|
||||
|
@ -660,29 +650,30 @@ DEPENDENCIES
|
|||
active_record_query_trace (~> 1.6)
|
||||
addressable (~> 2.6)
|
||||
annotate (~> 2.7)
|
||||
aws-sdk-s3 (~> 1.36)
|
||||
aws-sdk-s3 (~> 1.42)
|
||||
better_errors (~> 2.5)
|
||||
binding_of_caller (~> 0.7)
|
||||
blurhash (~> 0.1)
|
||||
bootsnap (~> 1.4)
|
||||
brakeman (~> 4.5)
|
||||
browser
|
||||
bullet (~> 5.9)
|
||||
bullet (~> 6.0)
|
||||
bundler-audit (~> 0.6)
|
||||
capistrano (~> 3.11)
|
||||
capistrano-rails (~> 1.4)
|
||||
capistrano-rbenv (~> 2.1)
|
||||
capistrano-yarn (~> 2.0)
|
||||
capybara (~> 3.16)
|
||||
capybara (~> 3.24)
|
||||
charlock_holmes (~> 0.7.6)
|
||||
chewy (~> 5.0)
|
||||
cld3 (~> 3.2.3)
|
||||
cld3 (~> 3.2.4)
|
||||
climate_control (~> 0.2)
|
||||
concurrent-ruby
|
||||
derailed_benchmarks
|
||||
devise (~> 4.6)
|
||||
devise-two-factor (~> 3.0)
|
||||
devise_pam_authenticatable2 (~> 9.2)
|
||||
doorkeeper (~> 5.0)
|
||||
doorkeeper (~> 5.1)
|
||||
dotenv-rails (~> 2.7)
|
||||
fabrication (~> 2.20)
|
||||
faker (~> 1.9)
|
||||
|
@ -690,7 +681,7 @@ DEPENDENCIES
|
|||
fastimage
|
||||
fog-core (<= 2.1.0)
|
||||
fog-openstack (~> 0.3)
|
||||
fuubar (~> 2.3)
|
||||
fuubar (~> 2.4)
|
||||
goldfinger (~> 2.1)
|
||||
hamlit-rails (~> 0.2)
|
||||
hiredis (~> 0.6)
|
||||
|
@ -699,7 +690,7 @@ DEPENDENCIES
|
|||
http (~> 3.3)
|
||||
http_accept_language (~> 2.1)
|
||||
http_parser.rb (~> 0.6)!
|
||||
httplog (~> 1.2)
|
||||
httplog (~> 1.3)
|
||||
i18n-tasks (~> 0.9)
|
||||
idn-ruby
|
||||
iso-639
|
||||
|
@ -709,7 +700,7 @@ DEPENDENCIES
|
|||
letter_opener (~> 1.7)
|
||||
letter_opener_web (~> 1.3)
|
||||
link_header (~> 0.0)
|
||||
lograge (~> 0.10)
|
||||
lograge (~> 0.11)
|
||||
makara (~> 0.4)
|
||||
mario-redis-lock (~> 1.2)
|
||||
memory_profiler
|
||||
|
@ -723,10 +714,10 @@ DEPENDENCIES
|
|||
omniauth-cas (~> 1.1)
|
||||
omniauth-saml (~> 1.10)
|
||||
ostatus2 (~> 2.0)
|
||||
ox (~> 2.10)
|
||||
ox (~> 2.11)
|
||||
paperclip (~> 6.0)
|
||||
paperclip-av-transcoder (~> 0.6)
|
||||
parallel_tests (~> 2.28)
|
||||
parallel_tests (~> 2.29)
|
||||
pg (~> 1.1)
|
||||
pghero (~> 2.2)
|
||||
pkg-config (~> 1.3)
|
||||
|
@ -737,22 +728,23 @@ DEPENDENCIES
|
|||
pry-rails (~> 0.3)
|
||||
puma (~> 3.12)
|
||||
pundit (~> 2.0)
|
||||
rack-attack (~> 5.4)
|
||||
rack-attack (~> 6.0)
|
||||
rack-cors (~> 1.0)
|
||||
rails (~> 5.2.3)
|
||||
rails-controller-testing (~> 1.0)
|
||||
rails-i18n (~> 5.1)
|
||||
rails-settings-cached (~> 0.6)
|
||||
rdf-normalize (~> 0.3)
|
||||
redcarpet (~> 3.4)
|
||||
redis (~> 4.1)
|
||||
redis-namespace (~> 1.5)
|
||||
redis-rails (~> 5.0)
|
||||
rqrcode (~> 0.10)
|
||||
rspec-rails (~> 3.8)
|
||||
rspec-sidekiq (~> 3.0)
|
||||
rubocop (~> 0.67)
|
||||
rubocop (~> 0.71)
|
||||
rubocop-rails (~> 2.0)
|
||||
sanitize (~> 5.0)
|
||||
scss_lint (~> 0.57)
|
||||
sidekiq (~> 5.2)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 3.0)
|
||||
|
@ -764,13 +756,13 @@ DEPENDENCIES
|
|||
stackprof
|
||||
stoplight (~> 2.1.3)
|
||||
streamio-ffmpeg (~> 3.0)
|
||||
strong_migrations (~> 0.3)
|
||||
strong_migrations (~> 0.4)
|
||||
thor (~> 0.20)
|
||||
tty-command (~> 0.8)
|
||||
tty-prompt (~> 0.18)
|
||||
tty-prompt (~> 0.19)
|
||||
twitter-text (~> 1.14)
|
||||
tzinfo-data (~> 1.2019)
|
||||
webmock (~> 3.5)
|
||||
webmock (~> 3.6)
|
||||
webpacker (~> 4.0)
|
||||
webpush
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -30,6 +30,7 @@ module Admin
|
|||
@trending_hashtags = TrendingTags.get(7)
|
||||
@profile_directory = Setting.profile_directory
|
||||
@timeline_preview = Setting.timeline_preview
|
||||
@keybase_integration = Setting.enable_keybase
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -13,13 +13,25 @@ module Admin
|
|||
authorize :domain_block, :create?
|
||||
|
||||
@domain_block = DomainBlock.new(resource_params)
|
||||
existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil
|
||||
|
||||
if @domain_block.save
|
||||
DomainBlockWorker.perform_async(@domain_block.id)
|
||||
log_action :create, @domain_block
|
||||
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
||||
else
|
||||
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
|
||||
@domain_block.save
|
||||
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
|
||||
@domain_block.errors[:domain].clear
|
||||
render :new
|
||||
else
|
||||
if existing_domain_block.present?
|
||||
@domain_block = existing_domain_block
|
||||
@domain_block.update(resource_params)
|
||||
end
|
||||
if @domain_block.save
|
||||
DomainBlockWorker.perform_async(@domain_block.id)
|
||||
log_action :create, @domain_block
|
||||
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -29,7 +41,7 @@ module Admin
|
|||
|
||||
def destroy
|
||||
authorize @domain_block, :destroy?
|
||||
UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
|
||||
UnblockDomainService.new.call(@domain_block)
|
||||
log_action :destroy, @domain_block
|
||||
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg')
|
||||
end
|
||||
|
@ -41,11 +53,7 @@ module Admin
|
|||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :retroactive)
|
||||
end
|
||||
|
||||
def retroactive_unblock?
|
||||
ActiveRecord::Type.lookup(:boolean).cast(resource_params[:retroactive])
|
||||
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
|
|||
skip_before_action :store_current_location
|
||||
skip_before_action :check_user_permissions
|
||||
|
||||
before_action :set_cache_headers
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
|
||||
|
@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
|
|||
def authorize_if_got_token!(*scopes)
|
||||
doorkeeper_authorize!(*scopes) if doorkeeper_token
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -3,6 +3,8 @@
|
|||
class Api::V1::CustomEmojisController < Api::BaseController
|
||||
respond_to :json
|
||||
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
def index
|
||||
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
|
||||
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::Instances::ActivityController < Api::BaseController
|
||||
before_action :require_enabled_api!
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::Instances::PeersController < Api::BaseController
|
||||
before_action :require_enabled_api!
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::InstancesController < Api::BaseController
|
||||
respond_to :json
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
def show
|
||||
render_cached_json('api:v1:instances', expires_in: 5.minutes) do
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -54,6 +54,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
scheduled_at: status_params[:scheduled_at],
|
||||
application: doorkeeper_token.application,
|
||||
poll: status_params[:poll],
|
||||
content_type: status_params[:content_type],
|
||||
idempotency: request.headers['Idempotency-Key'])
|
||||
|
||||
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||
|
@ -65,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
|
||||
render_empty
|
||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:spoiler_text,
|
||||
:visibility,
|
||||
:scheduled_at,
|
||||
:content_type,
|
||||
media_ids: [],
|
||||
poll: [
|
||||
:multiple,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -228,11 +228,6 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def mark_cacheable!
|
||||
skip_session!
|
||||
expires_in 0, public: true
|
||||
end
|
||||
|
||||
def skip_session!
|
||||
request.session_options[:skip] = true
|
||||
end
|
||||
end
|
||||
|
|
|
@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
end
|
||||
|
||||
def set_invite
|
||||
@invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
|
||||
invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
|
||||
@invite = invite&.valid_for_use? ? invite : nil
|
||||
end
|
||||
|
||||
def determine_layout
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -64,7 +64,7 @@ class HomeController < ApplicationController
|
|||
if request.path.start_with?('/web')
|
||||
new_user_session_path
|
||||
elsif single_user_mode?
|
||||
short_account_path(Account.local.where(suspended: false).first)
|
||||
short_account_path(Account.local.without_suspended.first)
|
||||
else
|
||||
about_path
|
||||
end
|
||||
|
|
|
@ -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?
|
||||
|
@ -37,6 +39,6 @@ class MediaProxyController < ApplicationController
|
|||
end
|
||||
|
||||
def reject_media?
|
||||
DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media?
|
||||
DomainBlock.reject_media?(@media_attachment.account.domain)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@ class Settings::IdentityProofsController < Settings::BaseController
|
|||
|
||||
before_action :authenticate_user!
|
||||
before_action :check_required_params, only: :new
|
||||
before_action :check_enabled, only: :new
|
||||
|
||||
def index
|
||||
@proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
|
||||
|
@ -41,6 +42,10 @@ class Settings::IdentityProofsController < Settings::BaseController
|
|||
|
||||
private
|
||||
|
||||
def check_enabled
|
||||
not_found unless Setting.enable_keybase
|
||||
end
|
||||
|
||||
def check_required_params
|
||||
redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
|
||||
end
|
||||
|
@ -56,8 +61,4 @@ class Settings::IdentityProofsController < Settings::BaseController
|
|||
def post_params
|
||||
params.require(:account_identity_proof).permit(:post_status, :status_text)
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = ''
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Settings::NotificationsController < Settings::BaseController
|
||||
def show; end
|
||||
|
||||
def update
|
||||
user_settings.update(user_settings_params.to_h)
|
||||
|
||||
if current_user.save
|
||||
redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg')
|
||||
else
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_settings
|
||||
UserSettingsDecorator.new(current_user)
|
||||
end
|
||||
|
||||
def user_settings_params
|
||||
params.require(:user).permit(
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
)
|
||||
end
|
||||
end
|
|
@ -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,8 +50,10 @@ class Settings::PreferencesController < Settings::BaseController
|
|||
:setting_hide_followers_count,
|
||||
:setting_aggregate_reblogs,
|
||||
:setting_show_application,
|
||||
:setting_advanced_layout,
|
||||
:setting_default_content_type,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
|
||||
interactions: %i(must_be_follower must_be_following)
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,7 +28,8 @@ class StatusesController < ApplicationController
|
|||
respond_to do |format|
|
||||
format.html do
|
||||
use_pack 'public'
|
||||
mark_cacheable! unless user_signed_in?
|
||||
|
||||
expires_in 10.seconds, public: true if current_account.nil?
|
||||
|
||||
@body_classes = 'with-modals'
|
||||
|
||||
|
@ -39,8 +40,6 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
format.json do
|
||||
mark_cacheable! unless @stream_entry.hidden?
|
||||
|
||||
render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
|
@ -49,8 +48,6 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
def activity
|
||||
skip_session!
|
||||
|
||||
render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
|
@ -60,7 +57,6 @@ class StatusesController < ApplicationController
|
|||
use_pack 'embed'
|
||||
raise ActiveRecord::RecordNotFound if @status.hidden?
|
||||
|
||||
skip_session!
|
||||
expires_in 180, public: true
|
||||
response.headers['X-Frame-Options'] = 'ALLOWALL'
|
||||
@autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay])
|
||||
|
@ -69,8 +65,6 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
def replies
|
||||
skip_session!
|
||||
|
||||
render json: replies_collection_presenter,
|
||||
serializer: ActivityPub::CollectionSerializer,
|
||||
adapter: ActivityPub::Adapter,
|
||||
|
|
|
@ -16,14 +16,14 @@ class StreamEntriesController < ApplicationController
|
|||
respond_to do |format|
|
||||
format.html do
|
||||
use_pack 'public'
|
||||
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status'
|
||||
|
||||
expires_in 5.minutes, public: true unless @stream_entry.hidden?
|
||||
|
||||
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity)
|
||||
end
|
||||
|
||||
format.atom do
|
||||
unless @stream_entry.hidden?
|
||||
skip_session!
|
||||
expires_in 3.minutes, public: true
|
||||
end
|
||||
expires_in 3.minutes, public: true unless @stream_entry.hidden?
|
||||
|
||||
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
|
||||
end
|
||||
|
@ -51,7 +51,7 @@ class StreamEntriesController < ApplicationController
|
|||
|
||||
def set_stream_entry
|
||||
@stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id])
|
||||
@type = @stream_entry.activity_type.downcase
|
||||
@type = 'status'
|
||||
|
||||
raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil?
|
||||
authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only?
|
||||
|
|
|
@ -2,8 +2,16 @@
|
|||
|
||||
module WellKnown
|
||||
class KeybaseProofConfigController < ActionController::Base
|
||||
before_action :check_enabled
|
||||
|
||||
def show
|
||||
render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_enabled
|
||||
head 404 unless Setting.enable_keybase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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;
|
||||
|
@ -41,3 +41,26 @@ delegate(document, '.modal-button', 'click', e => {
|
|||
|
||||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
});
|
||||
|
||||
const getProfileAvatarAnimationHandler = (swapTo) => {
|
||||
//animate avatar gifs on the profile page when moused over
|
||||
return ({ target }) => {
|
||||
const swapSrc = target.getAttribute(swapTo);
|
||||
//only change the img source if autoplay is off and the image src is actually different
|
||||
if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) {
|
||||
target.src = swapSrc;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
|
||||
|
||||
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '#account_header', 'change', ({ target }) => {
|
||||
const header = document.querySelector('.card .card__img img');
|
||||
const [file] = target.files || [];
|
||||
const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
|
||||
|
||||
header.src = url;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -46,6 +46,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
|||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
||||
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
|
||||
|
||||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
|
||||
|
||||
|
@ -67,6 +68,14 @@ const messages = defineMessages({
|
|||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
});
|
||||
|
||||
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
|
||||
|
||||
export const ensureComposeIsVisible = (getState, routerHistory) => {
|
||||
if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
|
||||
routerHistory.push('/statuses/new');
|
||||
}
|
||||
};
|
||||
|
||||
export function changeCompose(text) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
|
@ -80,16 +89,14 @@ export function cycleElefriendCompose() {
|
|||
};
|
||||
};
|
||||
|
||||
export function replyCompose(status, router) {
|
||||
export function replyCompose(status, routerHistory) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_REPLY,
|
||||
status: status,
|
||||
});
|
||||
|
||||
if (router && !getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -105,29 +112,25 @@ export function resetCompose() {
|
|||
};
|
||||
};
|
||||
|
||||
export function mentionCompose(account, router) {
|
||||
export function mentionCompose(account, routerHistory) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_MENTION,
|
||||
account: account,
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
};
|
||||
};
|
||||
|
||||
export function directCompose(account, router) {
|
||||
export function directCompose(account, routerHistory) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_DIRECT,
|
||||
account: account,
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -147,6 +150,7 @@ export function submitCompose(routerHistory) {
|
|||
}
|
||||
api(getState).post('/api/v1/statuses', {
|
||||
status,
|
||||
content_type: getState().getIn(['compose', 'content_type']),
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: media.map(item => item.get('id')),
|
||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||
|
@ -227,8 +231,8 @@ export function uploadCompose(files) {
|
|||
return function (dispatch, getState) {
|
||||
const uploadLimit = 4;
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||
const progress = new Array(files.length).fill(0);
|
||||
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||
|
||||
if (files.length + media.size > uploadLimit) {
|
||||
dispatch(showAlert(undefined, messages.uploadErrorLimit));
|
||||
|
@ -248,6 +252,8 @@ export function uploadCompose(files) {
|
|||
resizeImage(f).then(file => {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
// Account for disparity in size of original image and resized data
|
||||
total += file.size - f.size;
|
||||
|
||||
return api(getState).post('/api/v1/media', data, {
|
||||
onUploadProgress: function({ loaded }){
|
||||
|
@ -405,7 +411,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
|
|||
};
|
||||
};
|
||||
|
||||
export function selectComposeSuggestion(position, token, suggestion) {
|
||||
export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
return (dispatch, getState) => {
|
||||
let completion;
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
|
@ -422,6 +428,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
|
|||
position,
|
||||
token,
|
||||
completion,
|
||||
path,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
@ -514,6 +521,13 @@ export function changeComposeVisibility(value) {
|
|||
};
|
||||
};
|
||||
|
||||
export function changeComposeContentType(value) {
|
||||
return {
|
||||
type: COMPOSE_CONTENT_TYPE_CHANGE,
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
export function insertEmojiCompose(position, emoji) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -62,9 +62,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
let filtered = false;
|
||||
|
||||
if (notification.type === 'mention') {
|
||||
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
|
||||
const regex = regexFromFilters(filters);
|
||||
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
||||
|
||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import api from 'flavours/glitch/util/api';
|
|||
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { ensureComposeIsVisible } from './compose';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
|
@ -71,14 +72,16 @@ export function fetchStatusFail(id, error, skipLoading) {
|
|||
};
|
||||
};
|
||||
|
||||
export function redraft(status) {
|
||||
export function redraft(status, raw_text, content_type) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
content_type,
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteStatus(id, router, withRedraft = false) {
|
||||
export function deleteStatus(id, routerHistory, withRedraft = false) {
|
||||
return (dispatch, getState) => {
|
||||
let status = getState().getIn(['statuses', id]);
|
||||
|
||||
|
@ -88,16 +91,14 @@ export function deleteStatus(id, router, withRedraft = false) {
|
|||
|
||||
dispatch(deleteStatusRequest(id));
|
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
|
||||
if (withRedraft) {
|
||||
dispatch(redraft(status));
|
||||
dispatch(redraft(status, response.data.text, response.data.content_type));
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import api from 'flavours/glitch/util/api';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
|
||||
|
||||
export function fetchSuggestions() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchSuggestionsRequest());
|
||||
|
||||
api(getState).get('/api/v1/suggestions').then(response => {
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchSuggestionsSuccess(response.data));
|
||||
}).catch(error => dispatch(fetchSuggestionsFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsRequest() {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsSuccess(accounts) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsFail(error) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const dismissSuggestion = accountId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: SUGGESTIONS_DISMISS,
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
api(getState).delete(`/api/v1/suggestions/${accountId}`);
|
||||
};
|
|
@ -97,7 +97,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done =
|
|||
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
||||
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
||||
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
|
||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||
|
||||
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
||||
|
|
|
@ -31,6 +31,9 @@ export default class Account extends ImmutablePureComponent {
|
|||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
small: PropTypes.bool,
|
||||
actionIcon: PropTypes.string,
|
||||
actionTitle: PropTypes.string,
|
||||
onActionClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
|
@ -53,12 +56,19 @@ export default class Account extends ImmutablePureComponent {
|
|||
this.props.onMuteNotifications(this.props.account, false);
|
||||
}
|
||||
|
||||
handleAction = () => {
|
||||
this.props.onActionClick(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
account,
|
||||
hidden,
|
||||
intl,
|
||||
small,
|
||||
onActionClick,
|
||||
actionIcon,
|
||||
actionTitle,
|
||||
} = this.props;
|
||||
|
||||
if (!account) {
|
||||
|
@ -76,7 +86,9 @@ export default class Account extends ImmutablePureComponent {
|
|||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
|
||||
if (onActionClick && actionIcon) {
|
||||
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
|
||||
} else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
export default class AutosuggestEmoji extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='emoji'>
|
||||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
import React from 'react';
|
||||
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from 'flavours/glitch/util/rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import classNames from 'classnames';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
|
||||
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
export default class AutosuggestInput extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
searchTokens: PropTypes.arrayOf(PropTypes.string),
|
||||
maxLength: PropTypes.number,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
searchTokens: ImmutableList(['@', ':', '#']),
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
focused: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.which === 229 || e.isComposing) {
|
||||
// Ignore key events during text composition
|
||||
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (suggestions.size === 0 || suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setInput = (c) => {
|
||||
this.input = c;
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
style={style}
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,238 @@
|
|||
import React from 'react';
|
||||
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from 'flavours/glitch/util/rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
|
||||
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
focused: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.which === 229 || e.isComposing) {
|
||||
// Ignore key events during text composition
|
||||
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (suggestions.size === 0 || suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
}
|
||||
|
||||
onFocus = (e) => {
|
||||
this.setState({ focused: true });
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(e);
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea = (c) => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
onPaste = (e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return [
|
||||
<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>
|
||||
</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>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -8,10 +8,15 @@ export default class ColumnBackButton extends React.PureComponent {
|
|||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
handleClick = (event) => {
|
||||
// if history is exhausted, or we would leave mastodon, just go to root.
|
||||
if (window.history.state) {
|
||||
this.context.router.history.goBack();
|
||||
const state = this.context.router.history.location.state;
|
||||
if (event.shiftKey && state && state.mastodonBackSteps) {
|
||||
this.context.router.history.go(-state.mastodonBackSteps);
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
} else {
|
||||
this.context.router.history.push('/');
|
||||
}
|
||||
|
|
|
@ -8,10 +8,15 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
|
|||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
handleClick = (event) => {
|
||||
// if history is exhausted, or we would leave mastodon, just go to root.
|
||||
if (window.history.state) {
|
||||
this.context.router.history.goBack();
|
||||
const state = this.context.router.history.location.state;
|
||||
if (event.shiftKey && state && state.mastodonBackSteps) {
|
||||
this.context.router.history.go(-state.mastodonBackSteps);
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
} else {
|
||||
this.context.router.history.push('/');
|
||||
}
|
||||
|
|
|
@ -47,10 +47,15 @@ export default class ColumnHeader extends React.PureComponent {
|
|||
animatingNCD: false,
|
||||
};
|
||||
|
||||
historyBack = () => {
|
||||
historyBack = (skip) => {
|
||||
// if history is exhausted, or we would leave mastodon, just go to root.
|
||||
if (window.history.state) {
|
||||
this.context.router.history.goBack();
|
||||
const state = this.context.router.history.location.state;
|
||||
if (skip && state && state.mastodonBackSteps) {
|
||||
this.context.router.history.go(-state.mastodonBackSteps);
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
} else {
|
||||
this.context.router.history.push('/');
|
||||
}
|
||||
|
@ -73,8 +78,8 @@ export default class ColumnHeader extends React.PureComponent {
|
|||
this.props.onMove(1);
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
this.historyBack();
|
||||
handleBackClick = (event) => {
|
||||
this.historyBack(event.shiftKey);
|
||||
}
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -7,6 +7,7 @@ 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 { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
hidden: {
|
||||
|
@ -41,6 +42,7 @@ class Item extends React.PureComponent {
|
|||
letterbox: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayWidth: PropTypes.number,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -49,6 +51,10 @@ class Item extends React.PureComponent {
|
|||
size: 1,
|
||||
};
|
||||
|
||||
state = {
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
|
@ -82,13 +88,40 @@ class Item extends React.PureComponent {
|
|||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
if (pixels) {
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size, standalone, letterbox, displayWidth } = this.props;
|
||||
const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
|
@ -141,12 +174,20 @@ class Item extends React.PureComponent {
|
|||
|
||||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
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' }} title={attachment.get('description')}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||
|
||||
|
@ -173,6 +214,7 @@ class Item extends React.PureComponent {
|
|||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
@ -190,7 +232,6 @@ class Item extends React.PureComponent {
|
|||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
|
@ -203,7 +244,8 @@ class Item extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
{thumbnail}
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -215,7 +257,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,
|
||||
|
@ -226,6 +267,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 = {
|
||||
|
@ -233,13 +276,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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -252,7 +297,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) => {
|
||||
|
@ -263,6 +312,7 @@ export default class MediaGallery extends React.PureComponent {
|
|||
this.node = node;
|
||||
if (node && node.offsetWidth && node.offsetWidth != this.state.width) {
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
});
|
||||
|
@ -281,7 +331,7 @@ export default class MediaGallery extends React.PureComponent {
|
|||
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
let children;
|
||||
let children, spoilerButton;
|
||||
|
||||
const style = {};
|
||||
|
||||
|
@ -295,40 +345,32 @@ export default class MediaGallery extends React.PureComponent {
|
|||
return (<div className={computedClass} ref={this.handleRef}></div>);
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />;
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />);
|
||||
}
|
||||
|
||||
children = (
|
||||
<button className='media-spoiler' type='button' onClick={this.handleOpen}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span>
|
||||
if (visible) {
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||
} else {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||
{visible ? (
|
||||
<div className='sensitive-info'>
|
||||
<IconButton
|
||||
icon='eye'
|
||||
onClick={this.handleOpen}
|
||||
overlay
|
||||
title={intl.formatMessage(messages.toggle_visible)}
|
||||
/>
|
||||
{sensitive ? (
|
||||
<span className='sensitive-marker'>
|
||||
<FormattedMessage {...messages.sensitive} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||
{spoilerButton}
|
||||
{visible && sensitive && (
|
||||
<span className='sensitive-marker'>
|
||||
<FormattedMessage {...messages.sensitive} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -40,7 +40,7 @@ export default class ModalRoot extends React.PureComponent {
|
|||
this.setState({ revealed: false });
|
||||
}
|
||||
if (!nextProps.children && !!this.props.children) {
|
||||
this.activeElement.focus();
|
||||
this.activeElement.focus({ preventScroll: true });
|
||||
this.activeElement = null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,9 @@ export default class Permalink extends React.PureComponent {
|
|||
|
||||
if (this.context.router) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(this.props.to);
|
||||
let state = {...this.context.router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(this.props.to, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +39,22 @@ export const textForScreenReader = (intl, status, rebloggedByText = false, expan
|
|||
return values.join(', ');
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class Status extends ImmutablePureComponent {
|
||||
|
||||
|
@ -49,10 +66,12 @@ 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,
|
||||
onReblog: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
|
@ -65,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,
|
||||
|
@ -75,12 +95,16 @@ 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,
|
||||
}
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
@ -90,8 +114,6 @@ export default class Status extends ImmutablePureComponent {
|
|||
'account',
|
||||
'settings',
|
||||
'prepend',
|
||||
'boostModal',
|
||||
'favouriteModal',
|
||||
'muted',
|
||||
'collapse',
|
||||
'notification',
|
||||
|
@ -102,6 +124,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
updateOnStates = [
|
||||
'isExpanded',
|
||||
'isCollapsed',
|
||||
'showMedia',
|
||||
]
|
||||
|
||||
// If our settings have changed to disable collapsed statuses, then we
|
||||
|
@ -159,6 +182,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;
|
||||
}
|
||||
|
||||
|
@ -285,26 +322,40 @@ 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 router.history.push(destination);
|
||||
} 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);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
let state = {...this.context.router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(`/accounts/${id}`, state);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -331,17 +382,25 @@ export default class Status extends ImmutablePureComponent {
|
|||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleHotkeyBookmark = e => {
|
||||
this.props.onBookmark(this.props.status, e);
|
||||
}
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||
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);
|
||||
}
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
let state = {...this.context.router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
|
@ -352,6 +411,17 @@ export default class Status extends ImmutablePureComponent {
|
|||
this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
|
||||
}
|
||||
|
||||
handleHotkeyCollapse = e => {
|
||||
if (!this.props.settings.getIn(['collapsed', 'enabled']))
|
||||
return;
|
||||
|
||||
this.setCollapsed(!this.state.isCollapsed);
|
||||
}
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
}
|
||||
|
||||
handleRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
@ -376,6 +446,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
intl,
|
||||
status,
|
||||
account,
|
||||
otherAccounts,
|
||||
settings,
|
||||
collapsed,
|
||||
muted,
|
||||
|
@ -385,6 +456,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
onOpenMedia,
|
||||
notification,
|
||||
hidden,
|
||||
unread,
|
||||
featured,
|
||||
...other
|
||||
} = this.props;
|
||||
|
@ -449,15 +521,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')}
|
||||
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'])}
|
||||
|
@ -466,11 +539,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}>
|
||||
|
@ -484,7 +558,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>
|
||||
|
@ -540,12 +615,16 @@ export default class Status extends ImmutablePureComponent {
|
|||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
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');
|
||||
|
||||
|
@ -576,6 +655,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
friend={account}
|
||||
collapsed={isCollapsed}
|
||||
parseClick={parseClick}
|
||||
otherAccounts={otherAccounts}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
|
@ -585,6 +665,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
collapsible={settings.getIn(['collapsed', 'enabled'])}
|
||||
collapsed={isCollapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
directMessage={!!otherAccounts}
|
||||
/>
|
||||
</header>
|
||||
<StatusContent
|
||||
|
@ -602,6 +683,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
status={status}
|
||||
account={status.get('account')}
|
||||
showReplyCount={settings.get('show_reply_count')}
|
||||
directMessage={!!otherAccounts}
|
||||
/>
|
||||
) : null}
|
||||
{notification ? (
|
||||
|
|
|
@ -71,6 +71,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
onBookmark: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
showReplyCount: PropTypes.bool,
|
||||
directMessage: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -150,7 +151,13 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||
let state = {...this.context.router.history.location.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 = () => {
|
||||
|
@ -185,7 +192,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss, showReplyCount } = this.props;
|
||||
const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
|
@ -276,14 +283,15 @@ 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 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>,
|
||||
]}
|
||||
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
|
|
|
@ -213,6 +213,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
style={directionStyle}
|
||||
tabIndex={!hidden ? 0 : null}
|
||||
dangerouslySetInnerHTML={content}
|
||||
className='status__content__text'
|
||||
lang={status.get('language')}
|
||||
/>
|
||||
{media}
|
||||
|
@ -233,6 +234,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
ref={this.setRef}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.get('language')}
|
||||
className='status__content__text'
|
||||
tabIndex='0'
|
||||
/>
|
||||
{media}
|
||||
|
@ -245,7 +247,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
style={directionStyle}
|
||||
tabIndex='0'
|
||||
>
|
||||
<div ref={this.setRef} dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
|
||||
<div ref={this.setRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
|
||||
{media}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -22,6 +22,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,
|
||||
};
|
||||
|
@ -42,6 +43,7 @@ export default class StatusIcons extends React.PureComponent {
|
|||
mediaIcon,
|
||||
collapsible,
|
||||
collapsed,
|
||||
directMessage,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
|
@ -59,9 +61,7 @@ export default class StatusIcons extends React.PureComponent {
|
|||
aria-hidden='true'
|
||||
/>
|
||||
) : null}
|
||||
{(
|
||||
<VisibilityIcon visibility={status.get('visibility')} />
|
||||
)}
|
||||
{!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||
{collapsible ? (
|
||||
<IconButton
|
||||
className='status__collapse-button'
|
||||
|
|
|
@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
||||
}, 300, { leading: true })
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,13 +39,6 @@ export default class Mastodon extends React.PureComponent {
|
|||
window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
|
||||
}
|
||||
|
||||
// Protocol handler
|
||||
// Ask after 5 minutes
|
||||
if (typeof navigator.registerProtocolHandler !== 'undefined') {
|
||||
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
|
||||
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
store.dispatch(showOnboardingOnce());
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import Poll from 'flavours/glitch/components/poll';
|
||||
|
||||
const mapStateToProps = (state, { pollId }) => ({
|
||||
poll: state.getIn(['polls', pollId]),
|
||||
|
|
|
@ -88,19 +88,24 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
},
|
||||
|
||||
onModalReblog (status) {
|
||||
dispatch(reblog(status));
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if (e.shiftKey || !boostModal) {
|
||||
dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
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) {
|
||||
|
|
|
@ -1,69 +1,148 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Permalink from 'flavours/glitch/components/permalink';
|
||||
import { displayMedia } from 'flavours/glitch/util/initial_state';
|
||||
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { decode } from 'blurhash';
|
||||
import { isIOS } from 'flavours/glitch/util/is_mobile';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
displayWidth: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.state.visible) {
|
||||
this.setState({ visible: true });
|
||||
return true;
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
if (pixels) {
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
handleMouseEnter = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
hoverToPlay () {
|
||||
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.state.visible) {
|
||||
this.props.onOpenMedia(this.props.attachment);
|
||||
} else {
|
||||
this.setState({ visible: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
const { visible } = this.state;
|
||||
const status = media.get('status');
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
const style = {};
|
||||
const { attachment, displayWidth } = this.props;
|
||||
const { visible, loaded } = this.state;
|
||||
|
||||
let label, icon, title;
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
const height = width;
|
||||
const status = attachment.get('status');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
|
||||
if (media.get('type') === 'gifv') {
|
||||
label = <span className='media-gallery__gifv__label'>GIF</span>;
|
||||
}
|
||||
let thumbnail = '';
|
||||
|
||||
if (visible) {
|
||||
style.backgroundImage = `url(${media.get('preview_url')})`;
|
||||
style.backgroundPosition = `${x}% ${y}%`;
|
||||
title = media.get('description');
|
||||
} else {
|
||||
icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<i className='fa fa-eye-slash' />
|
||||
</span>
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
// Skip
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
thumbnail = (
|
||||
<img
|
||||
src={attachment.get('preview_url')}
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
|
||||
const autoPlay = !isIOS() && autoPlayGif;
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
title = status.get('spoiler_text') || media.get('description');
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<i className='fa fa-eye-slash' />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item'>
|
||||
<Permalink
|
||||
to={`/statuses/${status.get('id')}`}
|
||||
href={status.get('url')}
|
||||
style={style}
|
||||
title={title}
|
||||
onInterceptClick={this.handleClick}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Permalink>
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||
{visible ? thumbnail : icon}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
|
|||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
import LoadMore from 'flavours/glitch/components/load_more';
|
||||
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||
medias: getAccountGallery(state, props.params.accountId),
|
||||
attachments: getAccountGallery(state, props.params.accountId),
|
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
||||
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
||||
});
|
||||
|
||||
class LoadMoreMedia extends ImmutablePureComponent {
|
||||
|
@ -50,12 +51,16 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
medias: ImmutablePropTypes.list.isRequired,
|
||||
attachments: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
width: 323,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||
|
@ -74,11 +79,11 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
|
||||
handleScrollToBottom = () => {
|
||||
if (this.props.hasMore) {
|
||||
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
|
||||
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll = (e) => {
|
||||
handleScroll = e => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
|
@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
||||
};
|
||||
|
||||
handleLoadOlder = (e) => {
|
||||
handleLoadOlder = e => {
|
||||
e.preventDefault();
|
||||
this.handleScrollToBottom();
|
||||
}
|
||||
|
@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
return !(location.state && location.state.mastodonModalOpen);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleOpenMedia = attachment => {
|
||||
if (attachment.get('type') === 'video') {
|
||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||
} else {
|
||||
const media = attachment.getIn(['status', 'media_attachments']);
|
||||
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||
|
||||
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
|
||||
}
|
||||
}
|
||||
|
||||
handleRef = c => {
|
||||
if (c) {
|
||||
this.setState({ width: c.offsetWidth });
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { medias, isLoading, hasMore, isAccount } = this.props;
|
||||
const { attachments, isLoading, hasMore, isAccount } = this.props;
|
||||
const { width } = this.state;
|
||||
|
||||
if (!isAccount) {
|
||||
return (
|
||||
|
@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
let loadOlder = null;
|
||||
|
||||
if (!medias && isLoading) {
|
||||
if (!attachments && isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
|
@ -126,35 +147,31 @@ export default class AccountGallery extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (hasMore && !(isLoading && medias.size === 0)) {
|
||||
let loadOlder = null;
|
||||
|
||||
if (hasMore && !(isLoading && attachments.size === 0)) {
|
||||
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
<Column ref={this.setColumnRef}>
|
||||
<ProfileColumnHeader onClick={this.handleHeaderClick} />
|
||||
|
||||
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||
<HeaderContainer accountId={this.props.params.accountId} />
|
||||
|
||||
<div role='feed' className='account-gallery__container'>
|
||||
{medias.map((media, index) => media === null ? (
|
||||
<LoadMoreMedia
|
||||
key={'more:' + medias.getIn(index + 1, 'id')}
|
||||
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
/>
|
||||
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
|
||||
{attachments.map((attachment, index) => attachment === null ? (
|
||||
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
|
||||
) : (
|
||||
<MediaItem
|
||||
key={media.get('id')}
|
||||
media={media}
|
||||
/>
|
||||
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
|
||||
))}
|
||||
|
||||
{loadOlder}
|
||||
</div>
|
||||
|
||||
{isLoading && medias.size === 0 && (
|
||||
{isLoading && attachments.size === 0 && (
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
|
|
|
@ -20,7 +20,9 @@ export default class MovedNote extends ImmutablePureComponent {
|
|||
handleAccountClick = e => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.to.get('id')}`);
|
||||
let state = {...this.context.router.history.location.state};
|
||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||
this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ColumnSettings from '../components/column_settings';
|
||||
import { changeColumnParams } from 'flavours/glitch/actions/columns';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
|
||||
const mapStateToProps = (state, { columnId }) => {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import Avatar from 'flavours/glitch/components/avatar';
|
||||
import DisplayName from 'flavours/glitch/components/display_name';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class AutosuggestAccount extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
|
||||
return (
|
||||
<div className='account small' title={account.get('acct')}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
|
||||
<DisplayName account={account} inline />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import EmojiPicker from 'flavours/glitch/features/emoji_picker';
|
||||
import PollFormContainer from '../containers/poll_form_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { isMobile } from 'flavours/glitch/util/is_mobile';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { countableText } from 'flavours/glitch/util/counter';
|
||||
import OptionsContainer from '../containers/options_container';
|
||||
import Publisher from './publisher';
|
||||
import TextareaIcons from './textarea_icons';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||
missingDescriptionMessage: { id: 'confirmations.missing_media_description.message',
|
||||
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
|
||||
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
|
||||
defaultMessage: 'Send anyway' },
|
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class ComposeForm extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
text: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
spoiler: PropTypes.bool,
|
||||
privacy: PropTypes.string,
|
||||
spoilerText: PropTypes.string,
|
||||
focusDate: PropTypes.instanceOf(Date),
|
||||
caretPosition: PropTypes.number,
|
||||
preselectDate: PropTypes.instanceOf(Date),
|
||||
isSubmitting: PropTypes.bool,
|
||||
isChangingUpload: PropTypes.bool,
|
||||
isUploading: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
onSubmit: PropTypes.func,
|
||||
onClearSuggestions: PropTypes.func,
|
||||
onFetchSuggestions: PropTypes.func,
|
||||
onSuggestionSelected: PropTypes.func,
|
||||
onChangeSpoilerText: PropTypes.func,
|
||||
onPaste: PropTypes.func,
|
||||
onPickEmoji: PropTypes.func,
|
||||
showSearch: PropTypes.bool,
|
||||
anyMedia: PropTypes.bool,
|
||||
singleColumn: PropTypes.bool,
|
||||
|
||||
advancedOptions: ImmutablePropTypes.map,
|
||||
layout: PropTypes.string,
|
||||
media: ImmutablePropTypes.list,
|
||||
sideArm: PropTypes.string,
|
||||
sensitive: PropTypes.bool,
|
||||
spoilersAlwaysOn: PropTypes.bool,
|
||||
mediaDescriptionConfirmation: PropTypes.bool,
|
||||
preselectOnReply: PropTypes.bool,
|
||||
onChangeSpoilerness: PropTypes.func,
|
||||
onChangeVisibility: PropTypes.func,
|
||||
onPaste: PropTypes.func,
|
||||
onMediaDescriptionConfirm: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
showSearch: false,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleKeyDown = ({ ctrlKey, keyCode, metaKey, altKey }) => {
|
||||
// We submit the status on control/meta + enter.
|
||||
if (keyCode === 13 && (ctrlKey || metaKey)) {
|
||||
this.handleSubmit();
|
||||
}
|
||||
|
||||
// Submit the status with secondary visibility on alt + enter.
|
||||
if (keyCode === 13 && altKey) {
|
||||
this.handleSecondarySubmit();
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
const { textarea: { value }, uploadForm } = this;
|
||||
const {
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
isChangingUpload,
|
||||
isUploading,
|
||||
media,
|
||||
anyMedia,
|
||||
text,
|
||||
mediaDescriptionConfirmation,
|
||||
onMediaDescriptionConfirm,
|
||||
} = this.props;
|
||||
|
||||
// If something changes inside the textarea, then we update the
|
||||
// state before submitting.
|
||||
if (onChange && text !== value) {
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
// Submit disabled:
|
||||
if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit unless there are media with missing descriptions
|
||||
if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
|
||||
const firstWithoutDescription = media.findIndex(item => !item.get('description'));
|
||||
if (uploadForm) {
|
||||
const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
|
||||
if (inputs.length == media.size && firstWithoutDescription !== -1) {
|
||||
inputs[firstWithoutDescription].focus();
|
||||
}
|
||||
}
|
||||
onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
|
||||
} else if (onSubmit) {
|
||||
onSubmit(this.context.router ? this.context.router.history : null);
|
||||
}
|
||||
}
|
||||
|
||||
// Changes the text value of the spoiler.
|
||||
handleChangeSpoiler = ({ target: { value } }) => {
|
||||
const { onChangeSpoilerText } = this.props;
|
||||
if (onChangeSpoilerText) {
|
||||
onChangeSpoilerText(value);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.composeForm = c;
|
||||
};
|
||||
|
||||
// Inserts an emoji at the caret.
|
||||
handleEmoji = (data) => {
|
||||
const { textarea: { selectionStart } } = this;
|
||||
const { onPickEmoji } = this.props;
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(selectionStart, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handles the secondary submit button.
|
||||
handleSecondarySubmit = () => {
|
||||
const {
|
||||
onChangeVisibility,
|
||||
sideArm,
|
||||
} = this.props;
|
||||
if (sideArm !== 'none' && onChangeVisibility) {
|
||||
onChangeVisibility(sideArm);
|
||||
}
|
||||
this.handleSubmit();
|
||||
}
|
||||
|
||||
// Selects a suggestion from the autofill.
|
||||
onSuggestionSelected = (tokenStart, token, value) => {
|
||||
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
|
||||
}
|
||||
|
||||
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
|
||||
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
|
||||
}
|
||||
|
||||
// When the escape key is released, we focus the UI.
|
||||
handleKeyUp = ({ key }) => {
|
||||
if (key === 'Escape') {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Sets a reference to the textarea.
|
||||
setAutosuggestTextarea = (textareaComponent) => {
|
||||
if (textareaComponent) {
|
||||
this.textarea = textareaComponent.textarea;
|
||||
}
|
||||
}
|
||||
|
||||
// Sets a reference to the CW field.
|
||||
handleRefSpoilerText = (spoilerComponent) => {
|
||||
if (spoilerComponent) {
|
||||
this.spoilerText = spoilerComponent.input;
|
||||
}
|
||||
}
|
||||
|
||||
handleFocus = () => {
|
||||
if (this.composeForm && !this.props.singleColumn) {
|
||||
this.composeForm.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
// This statement does several things:
|
||||
// - If we're beginning a reply, and,
|
||||
// - Replying to zero or one users, places the cursor at the end
|
||||
// of the textbox.
|
||||
// - Replying to more than one user, selects any usernames past
|
||||
// the first; this provides a convenient shortcut to drop
|
||||
// everyone else from the conversation.
|
||||
componentDidUpdate (prevProps) {
|
||||
const {
|
||||
textarea,
|
||||
spoilerText,
|
||||
} = this;
|
||||
const {
|
||||
focusDate,
|
||||
caretPosition,
|
||||
isSubmitting,
|
||||
preselectDate,
|
||||
text,
|
||||
preselectOnReply,
|
||||
singleColumn,
|
||||
} = this.props;
|
||||
let selectionEnd, selectionStart;
|
||||
|
||||
// Caret/selection handling.
|
||||
if (focusDate !== prevProps.focusDate) {
|
||||
switch (true) {
|
||||
case preselectDate !== prevProps.preselectDate && preselectOnReply:
|
||||
selectionStart = text.search(/\s/) + 1;
|
||||
selectionEnd = text.length;
|
||||
break;
|
||||
case !isNaN(caretPosition) && caretPosition !== null:
|
||||
selectionStart = selectionEnd = caretPosition;
|
||||
break;
|
||||
default:
|
||||
selectionStart = selectionEnd = text.length;
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
textarea.focus();
|
||||
if (!singleColumn) textarea.scrollIntoView();
|
||||
}
|
||||
|
||||
// Refocuses the textarea after submitting.
|
||||
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
|
||||
textarea.focus();
|
||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||
if (this.props.spoiler) {
|
||||
if (spoilerText) {
|
||||
spoilerText.focus();
|
||||
}
|
||||
} else {
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
render () {
|
||||
const {
|
||||
handleEmoji,
|
||||
handleSecondarySubmit,
|
||||
handleSelect,
|
||||
handleSubmit,
|
||||
handleRefTextarea,
|
||||
} = this;
|
||||
const {
|
||||
advancedOptions,
|
||||
anyMedia,
|
||||
intl,
|
||||
isSubmitting,
|
||||
isChangingUpload,
|
||||
isUploading,
|
||||
layout,
|
||||
media,
|
||||
onChangeSpoilerness,
|
||||
onChangeVisibility,
|
||||
onClearSuggestions,
|
||||
onFetchSuggestions,
|
||||
onPaste,
|
||||
privacy,
|
||||
sensitive,
|
||||
showSearch,
|
||||
sideArm,
|
||||
spoiler,
|
||||
spoilerText,
|
||||
suggestions,
|
||||
text,
|
||||
spoilersAlwaysOn,
|
||||
} = this.props;
|
||||
|
||||
let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
|
||||
|
||||
return (
|
||||
<div className='composer' ref={this.setRef}>
|
||||
<WarningContainer />
|
||||
|
||||
<ReplyIndicatorContainer />
|
||||
|
||||
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={spoilerText}
|
||||
onChange={this.handleChangeSpoiler}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
disabled={!spoiler}
|
||||
ref={this.handleRefSpoilerText}
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={onFetchSuggestions}
|
||||
onSuggestionsClearRequested={onClearSuggestions}
|
||||
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
id='glitch.composer.spoiler.input'
|
||||
className='spoiler-input__input'
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
<TextareaIcons advancedOptions={advancedOptions} />
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
<PollFormContainer />
|
||||
</div>
|
||||
</AutosuggestTextarea>
|
||||
|
||||
<OptionsContainer
|
||||
advancedOptions={advancedOptions}
|
||||
disabled={isSubmitting}
|
||||
onChangeVisibility={onChangeVisibility}
|
||||
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
|
||||
onUpload={onPaste}
|
||||
privacy={privacy}
|
||||
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
|
||||
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
|
||||
/>
|
||||
|
||||
<Publisher
|
||||
countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
|
||||
disabled={disabledButton}
|
||||
onSecondarySubmit={handleSecondarySubmit}
|
||||
onSubmit={handleSubmit}
|
||||
privacy={privacy}
|
||||
sideArm={sideArm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay';
|
|||
|
||||
// Components.
|
||||
import IconButton from 'flavours/glitch/components/icon_button';
|
||||
import ComposerOptionsDropdownContent from './content';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
|
||||
// Utils.
|
||||
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
|
||||
|
@ -196,7 +196,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
|
|||
show={open}
|
||||
target={this}
|
||||
>
|
||||
<ComposerOptionsDropdownContent
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
onChange={onChange}
|
||||
onClose={handleClose}
|
|
@ -0,0 +1,219 @@
|
|||
// Package imports.
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import Toggle from 'react-toggle';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// Components.
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
|
||||
// Utils.
|
||||
import { withPassive } from 'flavours/glitch/util/dom_helpers';
|
||||
import Motion from 'flavours/glitch/util/optional_motion';
|
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
|
||||
|
||||
class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
name: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
options: PropTypes.shape({
|
||||
icon: PropTypes.string,
|
||||
meta: PropTypes.node,
|
||||
on: PropTypes.bool,
|
||||
text: PropTypes.node,
|
||||
}),
|
||||
};
|
||||
|
||||
handleActivate = (e) => {
|
||||
const {
|
||||
name,
|
||||
onChange,
|
||||
onClose,
|
||||
options: { on },
|
||||
} = this.props;
|
||||
|
||||
// If the escape key was pressed, we close the dropdown.
|
||||
if (e.key === 'Escape' && onClose) {
|
||||
onClose();
|
||||
|
||||
// Otherwise, we both close the dropdown and change the value.
|
||||
} else if (onChange && (!e.key || e.key === 'Enter')) {
|
||||
e.preventDefault(); // Prevents change in focus on click
|
||||
if ((on === null || typeof on === 'undefined') && onClose) {
|
||||
onClose();
|
||||
}
|
||||
onChange(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const {
|
||||
active,
|
||||
options: {
|
||||
icon,
|
||||
meta,
|
||||
on,
|
||||
text,
|
||||
},
|
||||
} = this.props;
|
||||
const computedClass = classNames('composer--options--dropdown--content--item', {
|
||||
active,
|
||||
lengthy: meta,
|
||||
'toggled-off': !on && on !== null && typeof on !== 'undefined',
|
||||
'toggled-on': on,
|
||||
'with-icon': icon,
|
||||
});
|
||||
|
||||
let prefix = null;
|
||||
|
||||
if (on !== null && typeof on !== 'undefined') {
|
||||
prefix = <Toggle checked={on} onChange={this.handleActivate} />;
|
||||
} else if (icon) {
|
||||
prefix = <Icon className='icon' fullwidth icon={icon} />
|
||||
}
|
||||
|
||||
// The result.
|
||||
return (
|
||||
<div
|
||||
className={computedClass}
|
||||
onClick={this.handleActivate}
|
||||
onKeyDown={this.handleActivate}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
>
|
||||
{prefix}
|
||||
|
||||
<div className='content'>
|
||||
<strong>{text}</strong>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// The spring to use with our motion.
|
||||
const springMotion = spring(1, {
|
||||
damping: 35,
|
||||
stiffness: 400,
|
||||
});
|
||||
|
||||
// The component.
|
||||
export default class ComposerOptionsDropdownContent extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
icon: PropTypes.string,
|
||||
meta: PropTypes.node,
|
||||
name: PropTypes.string.isRequired,
|
||||
on: PropTypes.bool,
|
||||
text: PropTypes.node,
|
||||
})),
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
style: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
// When the document is clicked elsewhere, we close the dropdown.
|
||||
handleDocumentClick = ({ target }) => {
|
||||
const { node } = this;
|
||||
const { onClose } = this.props;
|
||||
if (onClose && node && !node.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Stores our node in `this.node`.
|
||||
handleRef = (node) => {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
// On mounting, we add our listeners.
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, withPassive);
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
// On unmounting, we remove our listeners.
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const { mounted } = this.state;
|
||||
const {
|
||||
items,
|
||||
onChange,
|
||||
onClose,
|
||||
style,
|
||||
value,
|
||||
} = this.props;
|
||||
|
||||
// The result.
|
||||
return (
|
||||
<Motion
|
||||
defaultStyle={{
|
||||
opacity: 0,
|
||||
scaleX: 0.85,
|
||||
scaleY: 0.75,
|
||||
}}
|
||||
style={{
|
||||
opacity: springMotion,
|
||||
scaleX: springMotion,
|
||||
scaleY: springMotion,
|
||||
}}
|
||||
>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div
|
||||
className='composer--options--dropdown--content'
|
||||
ref={this.handleRef}
|
||||
style={{
|
||||
...style,
|
||||
opacity: opacity,
|
||||
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
|
||||
}}
|
||||
>
|
||||
{items ? items.map(
|
||||
({
|
||||
name,
|
||||
...rest
|
||||
}) => (
|
||||
<ComposerOptionsDropdownContentItem
|
||||
active={name === value}
|
||||
key={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
options={rest}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
// Package imports.
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Components.
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
|
||||
// Utils.
|
||||
import { conditionalRender } from 'flavours/glitch/util/react_helpers';
|
||||
import { signOutLink } from 'flavours/glitch/util/backend_links';
|
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({
|
||||
community: {
|
||||
defaultMessage: 'Local timeline',
|
||||
id: 'navigation_bar.community_timeline',
|
||||
},
|
||||
home_timeline: {
|
||||
defaultMessage: 'Home',
|
||||
id: 'tabs_bar.home',
|
||||
},
|
||||
logout: {
|
||||
defaultMessage: 'Logout',
|
||||
id: 'navigation_bar.logout',
|
||||
},
|
||||
notifications: {
|
||||
defaultMessage: 'Notifications',
|
||||
id: 'tabs_bar.notifications',
|
||||
},
|
||||
public: {
|
||||
defaultMessage: 'Federated timeline',
|
||||
id: 'navigation_bar.public_timeline',
|
||||
},
|
||||
settings: {
|
||||
defaultMessage: 'App settings',
|
||||
id: 'navigation_bar.app_settings',
|
||||
},
|
||||
start: {
|
||||
defaultMessage: 'Getting started',
|
||||
id: 'getting_started.heading',
|
||||
},
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Header extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
columns: ImmutablePropTypes.list,
|
||||
unreadNotifications: PropTypes.number,
|
||||
showNotificationsBadge: PropTypes.bool,
|
||||
intl: PropTypes.object,
|
||||
onSettingsClick: PropTypes.func,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
|
||||
|
||||
// Only renders the component if the column isn't being shown.
|
||||
const renderForColumn = conditionalRender.bind(null,
|
||||
columnId => !columns || !columns.some(
|
||||
column => column.get('id') === columnId
|
||||
)
|
||||
);
|
||||
|
||||
// The result.
|
||||
return (
|
||||
<nav className='drawer--header'>
|
||||
<Link
|
||||
aria-label={intl.formatMessage(messages.start)}
|
||||
title={intl.formatMessage(messages.start)}
|
||||
to='/getting-started'
|
||||
><Icon icon='asterisk' /></Link>
|
||||
{renderForColumn('HOME', (
|
||||
<Link
|
||||
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||
title={intl.formatMessage(messages.home_timeline)}
|
||||
to='/timelines/home'
|
||||
><Icon icon='home' /></Link>
|
||||
))}
|
||||
{renderForColumn('NOTIFICATIONS', (
|
||||
<Link
|
||||
aria-label={intl.formatMessage(messages.notifications)}
|
||||
title={intl.formatMessage(messages.notifications)}
|
||||
to='/notifications'
|
||||
>
|
||||
<span className='icon-badge-wrapper'>
|
||||
<Icon icon='bell' />
|
||||
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{renderForColumn('COMMUNITY', (
|
||||
<Link
|
||||
aria-label={intl.formatMessage(messages.community)}
|
||||
title={intl.formatMessage(messages.community)}
|
||||
to='/timelines/public/local'
|
||||
><Icon icon='users' /></Link>
|
||||
))}
|
||||
{renderForColumn('PUBLIC', (
|
||||
<Link
|
||||
aria-label={intl.formatMessage(messages.public)}
|
||||
title={intl.formatMessage(messages.public)}
|
||||
to='/timelines/public'
|
||||
><Icon icon='globe' /></Link>
|
||||
))}
|
||||
<a
|
||||
aria-label={intl.formatMessage(messages.settings)}
|
||||
onClick={onSettingsClick}
|
||||
href='#'
|
||||
title={intl.formatMessage(messages.settings)}
|
||||
><Icon icon='cogs' /></a>
|
||||
<a
|
||||
aria-label={intl.formatMessage(messages.logout)}
|
||||
data-method='delete'
|
||||
href={ signOutLink }
|
||||
title={intl.formatMessage(messages.logout)}
|
||||
><Icon icon='sign-out' /></a>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from 'flavours/glitch/components/avatar';
|
||||
import Permalink from 'flavours/glitch/components/permalink';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { profileLink } from 'flavours/glitch/util/backend_links';
|
||||
|
||||
export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
return (
|
||||
<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={48} />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -2,23 +2,17 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import {
|
||||
FormattedMessage,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
// Components.
|
||||
import IconButton from 'flavours/glitch/components/icon_button';
|
||||
import TextIconButton from 'flavours/glitch/components/text_icon_button';
|
||||
import Dropdown from './dropdown';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion';
|
||||
import {
|
||||
assignHandlers,
|
||||
hiddenComponent,
|
||||
} from 'flavours/glitch/util/react_helpers';
|
||||
import { pollLimits } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
// Messages.
|
||||
|
@ -35,6 +29,10 @@ const messages = defineMessages({
|
|||
defaultMessage: 'Adjust status privacy',
|
||||
id: 'privacy.change',
|
||||
},
|
||||
content_type: {
|
||||
defaultMessage: 'Content type',
|
||||
id: 'content-type.change',
|
||||
},
|
||||
direct_long: {
|
||||
defaultMessage: 'Post to mentioned users only',
|
||||
id: 'privacy.direct.long',
|
||||
|
@ -47,6 +45,10 @@ const messages = defineMessages({
|
|||
defaultMessage: 'Draw something',
|
||||
id: 'compose.attach.doodle',
|
||||
},
|
||||
html: {
|
||||
defaultMessage: 'HTML',
|
||||
id: 'compose.content-type.html',
|
||||
},
|
||||
local_only_long: {
|
||||
defaultMessage: 'Do not post to other instances',
|
||||
id: 'advanced_options.local-only.long',
|
||||
|
@ -55,6 +57,14 @@ const messages = defineMessages({
|
|||
defaultMessage: 'Local-only',
|
||||
id: 'advanced_options.local-only.short',
|
||||
},
|
||||
markdown: {
|
||||
defaultMessage: 'Markdown',
|
||||
id: 'compose.content-type.markdown',
|
||||
},
|
||||
plain: {
|
||||
defaultMessage: 'Plain text',
|
||||
id: 'compose.content-type.plain',
|
||||
},
|
||||
private_long: {
|
||||
defaultMessage: 'Post to followers only',
|
||||
id: 'privacy.private.long',
|
||||
|
@ -71,10 +81,6 @@ const messages = defineMessages({
|
|||
defaultMessage: 'Public',
|
||||
id: 'privacy.public.short',
|
||||
},
|
||||
sensitive: {
|
||||
defaultMessage: 'Mark media as sensitive',
|
||||
id: 'compose_form.sensitive',
|
||||
},
|
||||
spoiler: {
|
||||
defaultMessage: 'Hide text behind warning',
|
||||
id: 'compose_form.spoiler',
|
||||
|
@ -109,19 +115,44 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
// Handlers.
|
||||
const handlers = {
|
||||
export default @injectIntl
|
||||
class ComposerOptions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
acceptContentTypes: PropTypes.string,
|
||||
advancedOptions: ImmutablePropTypes.map,
|
||||
disabled: PropTypes.bool,
|
||||
allowMedia: PropTypes.bool,
|
||||
hasMedia: PropTypes.bool,
|
||||
allowPoll: PropTypes.bool,
|
||||
hasPoll: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChangeAdvancedOption: PropTypes.func,
|
||||
onChangeVisibility: PropTypes.func,
|
||||
onChangeContentType: PropTypes.func,
|
||||
onTogglePoll: PropTypes.func,
|
||||
onDoodleOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
onModalOpen: PropTypes.func,
|
||||
onToggleSpoiler: PropTypes.func,
|
||||
onUpload: PropTypes.func,
|
||||
privacy: PropTypes.string,
|
||||
contentType: PropTypes.string,
|
||||
resetFileKey: PropTypes.number,
|
||||
spoiler: PropTypes.bool,
|
||||
showContentTypeChoice: PropTypes.bool,
|
||||
};
|
||||
|
||||
// Handles file selection.
|
||||
handleChangeFiles ({ target: { files } }) {
|
||||
handleChangeFiles = ({ target: { files } }) => {
|
||||
const { onUpload } = this.props;
|
||||
if (files.length && onUpload) {
|
||||
onUpload(files);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Handles attachment clicks.
|
||||
handleClickAttach (name) {
|
||||
handleClickAttach = (name) => {
|
||||
const { fileElement } = this;
|
||||
const { onDoodleOpen } = this.props;
|
||||
|
||||
|
@ -138,36 +169,19 @@ const handlers = {
|
|||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Handles a ref to the file input.
|
||||
handleRefFileElement (fileElement) {
|
||||
handleRefFileElement = (fileElement) => {
|
||||
this.fileElement = fileElement;
|
||||
},
|
||||
};
|
||||
|
||||
// The component.
|
||||
export default class ComposerOptions extends React.PureComponent {
|
||||
|
||||
// Constructor.
|
||||
constructor (props) {
|
||||
super(props);
|
||||
assignHandlers(this, handlers);
|
||||
|
||||
// Instance variables.
|
||||
this.fileElement = null;
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const {
|
||||
handleChangeFiles,
|
||||
handleClickAttach,
|
||||
handleRefFileElement,
|
||||
} = this.handlers;
|
||||
const {
|
||||
acceptContentTypes,
|
||||
advancedOptions,
|
||||
contentType,
|
||||
disabled,
|
||||
allowMedia,
|
||||
hasMedia,
|
||||
|
@ -175,7 +189,7 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
hasPoll,
|
||||
intl,
|
||||
onChangeAdvancedOption,
|
||||
onChangeSensitivity,
|
||||
onChangeContentType,
|
||||
onChangeVisibility,
|
||||
onTogglePoll,
|
||||
onModalClose,
|
||||
|
@ -183,8 +197,8 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
onToggleSpoiler,
|
||||
privacy,
|
||||
resetFileKey,
|
||||
sensitive,
|
||||
spoiler,
|
||||
showContentTypeChoice,
|
||||
} = this.props;
|
||||
|
||||
// We predefine our privacy items so that we can easily pick the
|
||||
|
@ -216,6 +230,24 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
},
|
||||
};
|
||||
|
||||
const contentTypeItems = {
|
||||
plain: {
|
||||
icon: 'file-text',
|
||||
name: 'text/plain',
|
||||
text: <FormattedMessage {...messages.plain} />,
|
||||
},
|
||||
html: {
|
||||
icon: 'code',
|
||||
name: 'text/html',
|
||||
text: <FormattedMessage {...messages.html} />,
|
||||
},
|
||||
markdown: {
|
||||
icon: 'arrow-circle-down',
|
||||
name: 'text/markdown',
|
||||
text: <FormattedMessage {...messages.markdown} />,
|
||||
},
|
||||
};
|
||||
|
||||
// The result.
|
||||
return (
|
||||
<div className='composer--options'>
|
||||
|
@ -223,11 +255,11 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
accept={acceptContentTypes}
|
||||
disabled={disabled || !allowMedia}
|
||||
key={resetFileKey}
|
||||
onChange={handleChangeFiles}
|
||||
ref={handleRefFileElement}
|
||||
onChange={this.handleChangeFiles}
|
||||
ref={this.handleRefFileElement}
|
||||
type='file'
|
||||
multiple
|
||||
{...hiddenComponent}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Dropdown
|
||||
disabled={disabled || !allowMedia}
|
||||
|
@ -244,7 +276,7 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
text: <FormattedMessage {...messages.doodle} />,
|
||||
},
|
||||
]}
|
||||
onChange={handleClickAttach}
|
||||
onChange={this.handleClickAttach}
|
||||
onModalClose={onModalClose}
|
||||
onModalOpen={onModalOpen}
|
||||
title={intl.formatMessage(messages.attach)}
|
||||
|
@ -264,39 +296,6 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
|
||||
/>
|
||||
)}
|
||||
<Motion
|
||||
defaultStyle={{ scale: 0.87 }}
|
||||
style={{
|
||||
scale: spring(hasMedia ? 1 : 0.87, {
|
||||
stiffness: 200,
|
||||
damping: 3,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{({ scale }) => (
|
||||
<div
|
||||
style={{
|
||||
display: hasMedia ? null : 'none',
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
active={sensitive}
|
||||
className='sensitive'
|
||||
disabled={spoiler}
|
||||
icon={sensitive ? 'eye-slash' : 'eye'}
|
||||
inverted
|
||||
onClick={onChangeSensitivity}
|
||||
size={18}
|
||||
style={{
|
||||
height: null,
|
||||
lineHeight: null,
|
||||
}}
|
||||
title={intl.formatMessage(messages.sensitive)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
<hr />
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
|
@ -313,6 +312,22 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
title={intl.formatMessage(messages.change_privacy)}
|
||||
value={privacy}
|
||||
/>
|
||||
{showContentTypeChoice && (
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
|
||||
items={[
|
||||
contentTypeItems.plain,
|
||||
contentTypeItems.html,
|
||||
contentTypeItems.markdown,
|
||||
]}
|
||||
onChange={onChangeContentType}
|
||||
onModalClose={onModalClose}
|
||||
onModalOpen={onModalOpen}
|
||||
title={intl.formatMessage(messages.content_type)}
|
||||
value={contentType}
|
||||
/>
|
||||
)}
|
||||
{onToggleSpoiler && (
|
||||
<TextIconButton
|
||||
active={spoiler}
|
||||
|
@ -350,28 +365,3 @@ export default class ComposerOptions extends React.PureComponent {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
// Props.
|
||||
ComposerOptions.propTypes = {
|
||||
acceptContentTypes: PropTypes.string,
|
||||
advancedOptions: ImmutablePropTypes.map,
|
||||
disabled: PropTypes.bool,
|
||||
allowMedia: PropTypes.bool,
|
||||
hasMedia: PropTypes.bool,
|
||||
allowPoll: PropTypes.bool,
|
||||
hasPoll: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChangeAdvancedOption: PropTypes.func,
|
||||
onChangeSensitivity: PropTypes.func,
|
||||
onChangeVisibility: PropTypes.func,
|
||||
onTogglePoll: PropTypes.func,
|
||||
onDoodleOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
onModalOpen: PropTypes.func,
|
||||
onToggleSpoiler: PropTypes.func,
|
||||
onUpload: PropTypes.func,
|
||||
privacy: PropTypes.string,
|
||||
resetFileKey: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
spoiler: PropTypes.bool,
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue