Merge remote-tracking branch 'glitchsoc/master'

master
noiob 2019-07-18 04:47:40 +02:00
commit bd942ac2a5
678 changed files with 16227 additions and 8942 deletions

View File

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

View File

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

10
.dependabot/config.yml Normal file
View File

@ -0,0 +1,10 @@
version: 1
update_configs:
- package_manager: "ruby:bundler"
directory: "/"
update_schedule: "weekly"
- package_manager: "javascript"
directory: "/"
update_schedule: "weekly"

View File

@ -160,6 +160,21 @@ STREAMING_CLUSTER_NUM=1
# Maximum number of pinned posts
# MAX_PINNED_TOOTS=5
# Maximum allowed bio characters
# MAX_BIO_CHARS=500
# Maximim number of profile fields allowed
# MAX_PROFILE_FIELDS=4
# Maximum allowed display name characters
# MAX_DISPLAY_NAME_CHARS=30
# Maximum image and video/audio upload sizes
# Units are in bytes
# 1048576 bytes equals 1 megabyte
# MAX_IMAGE_SIZE=8388608
# MAX_VIDEO_SIZE=41943040
# LDAP authentication (optional)
# LDAP_ENABLED=true
# LDAP_HOST=localhost

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
patreon: mastodon
open_collective: mastodon

View File

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

37
.sass-lint.yml Normal file
View File

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

View File

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

View File

@ -43,4 +43,4 @@ Gruntfile.js
# for specific ignore
!.svgo.yml
!sass-lint/**/*.yml

View File

@ -3,6 +3,132 @@ Changelog
All notable changes to this project will be documented in this file.
## [2.9.2] - 2019-06-22
### Added
- Add `short_description` and `approval_required` to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/11146))
### Changed
- Change camera icon to paperclip icon in upload form ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11149))
### Fixed
- Fix audio-only OGG and WebM files not being processed as such ([Gargron](https://github.com/tootsuite/mastodon/pull/11151))
- Fix audio not being downloaded from remote servers ([Gargron](https://github.com/tootsuite/mastodon/pull/11145))
## [2.9.1] - 2019-06-22
### Added
- Add moderation API ([Gargron](https://github.com/tootsuite/mastodon/pull/9387))
- Add audio uploads ([Gargron](https://github.com/tootsuite/mastodon/pull/11123), [Gargron](https://github.com/tootsuite/mastodon/pull/11141))
### Changed
- Change domain blocks to automatically support subdomains ([Gargron](https://github.com/tootsuite/mastodon/pull/11138))
- Change Nanobox configuration to bring it up to date ([danhunsaker](https://github.com/tootsuite/mastodon/pull/11083))
### Removed
- Remove expensive counters from federation page in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11139))
### Fixed
- Fix converted media being saved with original extension and mime type ([Gargron](https://github.com/tootsuite/mastodon/pull/11130))
- Fix layout of identity proofs settings ([acid-chicken](https://github.com/tootsuite/mastodon/pull/11126))
- Fix active scope only returning suspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/11111))
- Fix sanitizer making block level elements unreadable ([Gargron](https://github.com/tootsuite/mastodon/pull/10836))
- Fix label for site theme not being translated in admin UI ([palindromordnilap](https://github.com/tootsuite/mastodon/pull/11121))
- Fix statuses not being filtered irreversibly in web UI under some circumstances ([ThibG](https://github.com/tootsuite/mastodon/pull/11113))
- Fix scrolling behaviour in compose form ([ThibG](https://github.com/tootsuite/mastodon/pull/11093))
## [2.9.0] - 2019-06-13
### Added
- **Add single-column mode in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10807), [Gargron](https://github.com/tootsuite/mastodon/pull/10848), [Gargron](https://github.com/tootsuite/mastodon/pull/11003), [Gargron](https://github.com/tootsuite/mastodon/pull/10961), [Hanage999](https://github.com/tootsuite/mastodon/pull/10915), [noellabo](https://github.com/tootsuite/mastodon/pull/10917), [abcang](https://github.com/tootsuite/mastodon/pull/10859), [Gargron](https://github.com/tootsuite/mastodon/pull/10820), [Gargron](https://github.com/tootsuite/mastodon/pull/10835), [Gargron](https://github.com/tootsuite/mastodon/pull/10809), [Gargron](https://github.com/tootsuite/mastodon/pull/10963), [noellabo](https://github.com/tootsuite/mastodon/pull/10883), [Hanage999](https://github.com/tootsuite/mastodon/pull/10839))
- Add waiting time to the list of pending accounts in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10985))
- Add a keyboard shortcut to hide/show media in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10647), [Gargron](https://github.com/tootsuite/mastodon/pull/10838), [ThibG](https://github.com/tootsuite/mastodon/pull/10872))
- Add `account_id` param to `GET /api/v1/notifications` ([pwoolcoc](https://github.com/tootsuite/mastodon/pull/10796))
- Add confirmation modal for unboosting toots in web UI ([aurelien-reeves](https://github.com/tootsuite/mastodon/pull/10287))
- Add emoji suggestions to content warning and poll option fields in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10555))
- Add `source` attribute to response of `DELETE /api/v1/statuses/:id` ([ThibG](https://github.com/tootsuite/mastodon/pull/10669))
- Add some caching for HTML versions of public status pages ([ThibG](https://github.com/tootsuite/mastodon/pull/10701))
- Add button to conveniently copy OAuth code ([ThibG](https://github.com/tootsuite/mastodon/pull/11065))
### Changed
- **Change default layout to single column in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/10847))
- **Change light theme** ([Gargron](https://github.com/tootsuite/mastodon/pull/10992), [Gargron](https://github.com/tootsuite/mastodon/pull/10996), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10754), [Gargron](https://github.com/tootsuite/mastodon/pull/10845))
- **Change preferences page into appearance, notifications, and other** ([Gargron](https://github.com/tootsuite/mastodon/pull/10977), [Gargron](https://github.com/tootsuite/mastodon/pull/10988))
- Change priority of delete activity forwards for replies and reblogs ([Gargron](https://github.com/tootsuite/mastodon/pull/11002))
- Change Mastodon logo to use primary text color of the given theme ([Gargron](https://github.com/tootsuite/mastodon/pull/10994))
- Change reblogs counter to be updated when boosted privately ([Gargron](https://github.com/tootsuite/mastodon/pull/10964))
- Change bio limit from 160 to 500 characters ([trwnh](https://github.com/tootsuite/mastodon/pull/10790))
- Change API rate limiting to reduce allowed unauthenticated requests ([ThibG](https://github.com/tootsuite/mastodon/pull/10860), [hinaloe](https://github.com/tootsuite/mastodon/pull/10868), [mayaeh](https://github.com/tootsuite/mastodon/pull/10867))
- Change help text of `tootctl emoji import` command to specify a gzipped TAR archive is required ([dariusk](https://github.com/tootsuite/mastodon/pull/11000))
- Change web UI to hide poll options behind content warnings ([ThibG](https://github.com/tootsuite/mastodon/pull/10983))
- Change silencing to ensure local effects and remote effects are the same for silenced local users ([ThibG](https://github.com/tootsuite/mastodon/pull/10575))
- Change `tootctl domains purge` to remove custom emoji as well ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10721))
- Change Docker image to keep `apt` working ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10830))
### Removed
- Remove `dist-upgrade` from Docker image ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10822))
### Fixed
- Fix RTL layout not being RTL within the columns area in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10990))
- Fix display of alternative text when a media attachment is not available in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10981))
- Fix not being able to directly switch between list timelines in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10973))
- Fix media sensitivity not being maintained in delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10980))
- Fix emoji picker being always displayed in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/10979), [yuzulabo](https://github.com/tootsuite/mastodon/pull/10801), [wcpaez](https://github.com/tootsuite/mastodon/pull/10978))
- Fix potential private status leak through caching ([ThibG](https://github.com/tootsuite/mastodon/pull/10969))
- Fix refreshing featured toots when the new collection is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10971))
- Fix undoing domain block also undoing individual moderation on users from before the domain block ([ThibG](https://github.com/tootsuite/mastodon/pull/10660))
- Fix time not being local in the audit log ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10751))
- Fix statuses removed by moderation re-appearing on subsequent fetches ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10732))
- Fix misattribution of inlined announces if `attributedTo` isn't present in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10967))
- Fix `GET /api/v1/polls/:id` not requiring authentication for non-public polls ([Gargron](https://github.com/tootsuite/mastodon/pull/10960))
- Fix handling of blank poll options in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/10946))
- Fix avatar preview aspect ratio on edit profile page ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10931))
- Fix web push notifications not being sent for polls ([ThibG](https://github.com/tootsuite/mastodon/pull/10864))
- Fix cut off letters in last paragraph of statuses in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/10821))
- Fix list not being automatically unpinned when it returns 404 in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11045))
- Fix login sometimes redirecting to paths that are not pages ([Gargron](https://github.com/tootsuite/mastodon/pull/11019))
## [2.8.4] - 2019-05-24
### Fixed
- Fix delivery not retrying on some inbox errors that should be retriable ([ThibG](https://github.com/tootsuite/mastodon/pull/10812))
- Fix unnecessary 5 minute cooldowns on signature verifications in some cases ([ThibG](https://github.com/tootsuite/mastodon/pull/10813))
- Fix possible race condition when processing statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10815))
### Security
- Require specific OAuth scopes for specific endpoints of the streaming API, instead of merely requiring a token for all endpoints, and allow using WebSockets protocol negotiation to specify the access token instead of using a query string ([ThibG](https://github.com/tootsuite/mastodon/pull/10818))
## [2.8.3] - 2019-05-19
### Added
- Add `og:image:alt` OpenGraph tag ([BenLubar](https://github.com/tootsuite/mastodon/pull/10779))
- Add clickable area below avatar in statuses in web UI ([Dar13](https://github.com/tootsuite/mastodon/pull/10766))
- Add crossed-out eye icon on account gallery in web UI ([Kjwon15](https://github.com/tootsuite/mastodon/pull/10715))
- Add media description tooltip to thumbnails in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10713))
### Changed
- Change "mark as sensitive" button into a checkbox for clarity ([ThibG](https://github.com/tootsuite/mastodon/pull/10748))
### Fixed
- Fix bug allowing users to publicly boost their private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10775), [ThibG](https://github.com/tootsuite/mastodon/pull/10783))
- Fix performance in formatter by a little ([ThibG](https://github.com/tootsuite/mastodon/pull/10765))
- Fix some colors in the light theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10754))
- Fix some colors of the high contrast theme ([yuzulabo](https://github.com/tootsuite/mastodon/pull/10711))
- Fix ambivalent active state of poll refresh button in web UI ([MaciekBaron](https://github.com/tootsuite/mastodon/pull/10720))
- Fix duplicate posting being possible from web UI ([hinaloe](https://github.com/tootsuite/mastodon/pull/10785))
- Fix "invited by" not showing up in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10791))
## [2.8.2] - 2019-05-05
### Added

View File

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

View File

@ -52,7 +52,9 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://
## Translations
You can submit translations via pull request.
You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase.
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
## Pull requests

View File

@ -7,7 +7,6 @@ SHELL ["bash", "-c"]
ENV NODE_VER="8.15.0"
RUN echo "Etc/UTC" > /etc/localtime && \
apt update && \
apt -y dist-upgrade && \
apt -y install wget make gcc g++ python && \
cd ~ && \
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \
@ -80,13 +79,12 @@ ARG GID=991
RUN apt update && \
echo "Etc/UTC" > /etc/localtime && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \
apt -y dist-upgrade && \
apt install -y whois wget && \
addgroup --gid $GID mastodon && \
useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \
echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd
# Install masto runtime deps
# Install mastodon runtime deps
RUN apt -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg \
libicu60 libprotobuf10 libidn11 libyaml-0-2 \
@ -95,7 +93,7 @@ RUN apt -y --no-install-recommends install \
ln -s /opt/mastodon /mastodon && \
gem install bundler && \
rm -rf /var/cache && \
rm -rf /var/lib/apt
rm -rf /var/lib/apt/lists/*
# Add tini
ENV TINI_VERSION="0.18.0"
@ -104,11 +102,11 @@ ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tin
RUN echo "$TINI_SUM tini" | sha256sum -c -
RUN chmod +x /tini
# Copy over masto source, and dependencies from building, and set permissions
# Copy over mastodon source, and dependencies from building, and set permissions
COPY --chown=mastodon:mastodon . /opt/mastodon
COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon
# Run masto services in prod mode
# Run mastodon services in prod mode
ENV RAILS_ENV="production"
ENV NODE_ENV="production"

23
Gemfile
View File

@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.39', require: false
gem 'aws-sdk-s3', '~> 1.45', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -54,7 +54,7 @@ gem 'htmlentities', '~> 4.3'
gem 'http', '~> 3.3'
gem 'http_accept_language', '~> 2.1'
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2'
gem 'httplog', '~> 1.2'
gem 'httplog', '~> 1.3'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.1'
gem 'link_header', '~> 0.0'
@ -63,7 +63,7 @@ gem 'nokogiri', '~> 1.10'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.7'
gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.10'
gem 'ox', '~> 2.11'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'premailer-rails'
@ -83,9 +83,9 @@ gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 4.1'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.1.3'
gem 'strong_migrations', '~> 0.3'
gem 'strong_migrations', '~> 0.4'
gem 'tty-command', '~> 0.8', require: false
gem 'tty-prompt', '~> 0.18', require: false
gem 'tty-prompt', '~> 0.19', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.0'
@ -99,7 +99,7 @@ gem 'redcarpet', '~> 3.4'
group :development, :test do
gem 'fabrication', '~> 2.20'
gem 'fuubar', '~> 2.3'
gem 'fuubar', '~> 2.4'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.7'
gem 'pry-rails', '~> 0.3'
@ -111,14 +111,14 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.20'
gem 'capybara', '~> 3.25'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5'
gem 'simplecov', '~> 0.17', require: false
gem 'webmock', '~> 3.6'
gem 'parallel_tests', '~> 2.29'
end
@ -131,10 +131,10 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.69', require: false
gem 'rubocop', '~> 0.72', require: false
gem 'rubocop-rails', '~> 2.2', require: false
gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.58', require: false
gem 'capistrano', '~> 3.11'
gem 'capistrano-rails', '~> 1.4'
@ -151,3 +151,4 @@ group :production do
end
gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false

View File

@ -76,19 +76,19 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.3)
aws-partitions (1.162.0)
aws-sdk-core (3.52.1)
aws-partitions (1.184.0)
aws-sdk-core (3.59.0)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.20.0)
aws-sdk-core (~> 3, >= 3.52.1)
aws-sdk-kms (1.23.0)
aws-sdk-core (~> 3, >= 3.58.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.39.0)
aws-sdk-core (~> 3, >= 3.52.1)
aws-sdk-s3 (1.45.0)
aws-sdk-core (~> 3, >= 3.58.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.0)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.12)
@ -106,7 +106,7 @@ GEM
brakeman (4.5.1)
browser (2.5.3)
builder (3.2.3)
bullet (6.0.0)
bullet (6.0.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
@ -129,14 +129,13 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.20.0)
capybara (3.25.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (~> 1.2)
uglifier
regexp_parser (~> 1.5)
xpath (~> 3.2)
case_transform (0.2)
activesupport
@ -160,7 +159,7 @@ GEM
css_parser (1.6.0)
addressable
debug_inspector (0.0.3)
derailed_benchmarks (1.3.5)
derailed_benchmarks (1.3.6)
benchmark-ips (~> 2)
get_process_mem (~> 0)
heapy (~> 0)
@ -184,14 +183,14 @@ GEM
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.3)
docile (1.3.0)
docile (1.3.2)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.1.0)
railties (>= 5)
dotenv (2.7.2)
dotenv-rails (2.7.2)
dotenv (= 2.7.2)
dotenv (2.7.4)
dotenv-rails (2.7.4)
dotenv (= 2.7.4)
railties (>= 3.2, < 6.1)
elasticsearch (6.0.2)
elasticsearch-api (= 6.0.2)
@ -208,9 +207,8 @@ GEM
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
execjs (2.7.0)
fabrication (2.20.2)
faker (1.9.3)
faker (1.9.6)
i18n (>= 0.7)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
@ -233,7 +231,7 @@ GEM
fugit (1.1.6)
et-orbi (~> 1.1, >= 1.1.6)
raabro (~> 1.1)
fuubar (2.3.2)
fuubar (2.4.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
get_process_mem (0.2.3)
@ -255,7 +253,7 @@ GEM
railties (>= 4.0.1)
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.3.7)
hashdiff (0.4.0)
hashie (3.6.0)
heapy (0.1.4)
highline (2.0.1)
@ -273,7 +271,7 @@ GEM
domain_name (~> 0.5)
http-form_data (2.1.1)
http_accept_language (2.1.1)
httplog (1.2.2)
httplog (1.3.1)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.6.0)
@ -291,9 +289,9 @@ GEM
idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.2.8)
jaro_winkler (1.5.2)
jaro_winkler (1.5.3)
jmespath (1.4.0)
json (2.1.0)
json (2.2.0)
json-ld (3.0.2)
multi_json (~> 1.12)
rdf (>= 2.2.8, < 4.0)
@ -324,7 +322,7 @@ GEM
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.11.0)
lograge (0.11.2)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@ -340,7 +338,7 @@ GEM
mimemagic (~> 0.3.2)
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
memory_profiler (0.9.13)
memory_profiler (0.9.14)
method_source (0.9.2)
microformats (4.1.0)
json (~> 2.1)
@ -355,7 +353,7 @@ GEM
msgpack (1.2.10)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.4.0)
necromancer (0.5.0)
net-ldap (0.16.1)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
@ -386,7 +384,7 @@ GEM
addressable (~> 2.5)
http (~> 3.0)
nokogiri (~> 1.8)
ox (2.10.0)
ox (2.11.0)
paperclip (6.0.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
@ -397,7 +395,7 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.17.0)
parallel_tests (2.29.0)
parallel_tests (2.29.1)
parallel
parser (2.6.3.0)
ast (~> 2.4.0)
@ -405,7 +403,7 @@ GEM
equatable (~> 0.5.0)
tty-color (~> 0.4.0)
pg (1.1.4)
pghero (2.2.0)
pghero (2.2.1)
activerecord
pkg-config (1.3.7)
premailer (1.11.1)
@ -424,7 +422,7 @@ GEM
pry (~> 0.10)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (3.0.3)
public_suffix (3.1.1)
puma (3.12.1)
pundit (2.0.1)
activesupport (>= 3.0.0)
@ -474,16 +472,13 @@ GEM
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
rdf (3.0.9)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.3)
rdf (>= 2.2, < 4.0)
redcarpet (3.4.0)
redis (4.1.1)
redis (4.1.2)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
@ -502,7 +497,7 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.5.0)
redis (>= 2.2, < 5)
regexp_parser (1.5.0)
regexp_parser (1.5.1)
request_store (1.4.1)
rack (>= 1.4)
responders (2.4.1)
@ -532,31 +527,26 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rubocop (0.69.0)
rubocop (0.72.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.6)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.0)
rubocop-rails (2.2.0)
rack (>= 1.1)
rubocop (>= 0.72.0)
ruby-progressbar (1.10.1)
ruby-saml (1.9.0)
nokogiri (>= 1.5.10)
rufus-scheduler (3.5.2)
fugit (~> 1.1, >= 1.1.5)
safe_yaml (1.0.4)
safe_yaml (1.0.5)
sanitize (5.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
scss_lint (0.58.0)
rake (>= 0.9, < 13)
sass (~> 3.5, >= 3.5.5)
sidekiq (5.2.7)
connection_pool (~> 2.2, >= 2.2.2)
rack (>= 1.5.0)
@ -578,7 +568,7 @@ GEM
simple_form (4.1.0)
actionpack (>= 5.0)
activemodel (>= 5.0)
simplecov (0.16.1)
simplecov (0.17.0)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
@ -598,8 +588,8 @@ GEM
stoplight (2.1.3)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
strong_migrations (0.3.1)
activerecord (>= 3.2.0)
strong_migrations (0.4.0)
activerecord (>= 5)
temple (0.8.1)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
@ -608,30 +598,25 @@ GEM
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.9)
timers (4.2.0)
tty-color (0.4.3)
tty-command (0.8.2)
pastel (~> 0.7.0)
tty-cursor (0.6.0)
tty-prompt (0.18.1)
necromancer (~> 0.4.0)
tty-cursor (0.7.0)
tty-prompt (0.19.0)
necromancer (~> 0.5.0)
pastel (~> 0.7.0)
timers (~> 4.0)
tty-cursor (~> 0.6.0)
tty-reader (~> 0.5.0)
tty-reader (0.5.0)
tty-cursor (~> 0.6.0)
tty-screen (~> 0.6.4)
tty-reader (~> 0.6.0)
tty-reader (0.6.0)
tty-cursor (~> 0.7)
tty-screen (~> 0.7)
wisper (~> 2.0.0)
tty-screen (0.6.5)
tty-screen (0.7.0)
twitter-text (1.14.7)
unf (~> 0.1.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2019.1)
tzinfo-data (1.2019.2)
tzinfo (>= 1.0.0)
uglifier (4.1.20)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.5)
@ -639,11 +624,11 @@ GEM
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
webmock (3.5.1)
webmock (3.6.0)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpacker (4.0.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (4.0.7)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
@ -665,7 +650,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.6)
addressable (~> 2.6)
annotate (~> 2.7)
aws-sdk-s3 (~> 1.39)
aws-sdk-s3 (~> 1.45)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@ -678,12 +663,13 @@ DEPENDENCIES
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.20)
capybara (~> 3.25)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
connection_pool
derailed_benchmarks
devise (~> 4.6)
devise-two-factor (~> 3.0)
@ -696,7 +682,7 @@ DEPENDENCIES
fastimage
fog-core (<= 2.1.0)
fog-openstack (~> 0.3)
fuubar (~> 2.3)
fuubar (~> 2.4)
goldfinger (~> 2.1)
hamlit-rails (~> 0.2)
hiredis (~> 0.6)
@ -705,7 +691,7 @@ DEPENDENCIES
http (~> 3.3)
http_accept_language (~> 2.1)
http_parser.rb (~> 0.6)!
httplog (~> 1.2)
httplog (~> 1.3)
i18n-tasks (~> 0.9)
idn-ruby
iso-639
@ -729,7 +715,7 @@ DEPENDENCIES
omniauth-cas (~> 1.1)
omniauth-saml (~> 1.10)
ostatus2 (~> 2.0)
ox (~> 2.10)
ox (~> 2.11)
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.29)
@ -757,27 +743,27 @@ DEPENDENCIES
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.69)
rubocop (~> 0.72)
rubocop-rails (~> 2.2)
sanitize (~> 5.0)
scss_lint (~> 0.58)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.0)
simple_form (~> 4.1)
simplecov (~> 0.16)
simplecov (~> 0.17)
sprockets-rails (~> 3.2)
stackprof
stoplight (~> 2.1.3)
streamio-ffmpeg (~> 3.0)
strong_migrations (~> 0.3)
strong_migrations (~> 0.4)
thor (~> 0.20)
tty-command (~> 0.8)
tty-prompt (~> 0.18)
tty-prompt (~> 0.19)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2019)
webmock (~> 3.5)
webmock (~> 3.6)
webpacker (~> 4.0)
webpush

View File

@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index
field :id, type: 'long'
field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ module Admin
authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params)
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ class Api::V1::CustomEmojisController < Api::BaseController
def index
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false).includes(:category), each_serializer: REST::CustomEmojiSerializer)
end
end
end

View File

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

View File

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

View File

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

View File

@ -5,8 +5,8 @@ class Api::V1::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy]
before_action :require_user!, except: [:show, :context, :card]
before_action :set_status, only: [:show, :context, :card]
before_action :require_user!, except: [:show, :context]
before_action :set_status, only: [:show, :context]
respond_to :json
@ -33,16 +33,6 @@ class Api::V1::StatusesController < Api::BaseController
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
def card
@card = @status.preview_cards.first
if @card.nil?
render_empty
else
render json: @card, serializer: REST::PreviewCardSerializer
end
end
def create
@status = PostStatusService.new.call(current_user.account,
text: status_params[:status],

View File

@ -27,16 +27,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
end
def direct_timeline_statuses
# this query requires built in pagination.
Status.as_direct_timeline(
current_account,
account_direct_feed.get(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id],
true # returns array of cache_ids object
params[:min_id]
)
end
def account_direct_feed
DirectFeed.new(current_account)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end

View File

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

View File

@ -161,11 +161,15 @@ class ApplicationController < ActionController::Base
end
def current_account
@current_account ||= current_user.try(:account)
return @current_account if defined?(@current_account)
@current_account = current_user&.account
end
def current_session
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
return @current_session if defined?(@current_session)
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
end
def current_flavour
@ -228,11 +232,6 @@ class ApplicationController < ActionController::Base
end
def mark_cacheable!
skip_session!
expires_in 0, public: true
end
def skip_session!
request.session_options[:skip] = true
end
end

View File

@ -70,7 +70,6 @@ module AccountControllerConcern
def check_account_suspension
if @account.suspended?
skip_session!
expires_in(3.minutes, public: true)
gone
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,8 +61,4 @@ class Settings::IdentityProofsController < Settings::BaseController
def post_params
params.require(:account_identity_proof).permit(:post_status, :status_text)
end
def set_body_classes
@body_classes = ''
end
end

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
class Settings::NotificationsController < Settings::BaseController
def show; end
def update
user_settings.update(user_settings_params.to_h)
if current_user.save
redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
private
def user_settings
UserSettingsDecorator.new(current_user)
end
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
)
end
end

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ class Settings::PreferencesController < Settings::BaseController
if current_user.update(user_params)
I18n.locale = current_user.locale
redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
@ -16,6 +16,10 @@ class Settings::PreferencesController < Settings::BaseController
private
def after_update_redirect_path
settings_preferences_path
end
def user_settings
UserSettingsDecorator.new(current_user)
end
@ -46,7 +50,10 @@ class Settings::PreferencesController < Settings::BaseController
:setting_hide_followers_count,
:setting_aggregate_reblogs,
:setting_show_application,
:setting_advanced_layout,
:setting_default_content_type,
:setting_use_blurhash,
:setting_use_pending_items,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
)

View File

@ -29,10 +29,7 @@ class StatusesController < ApplicationController
format.html do
use_pack 'public'
unless user_signed_in?
skip_session!
expires_in 10.seconds, public: true
end
expires_in 10.seconds, public: true if current_account.nil?
@body_classes = 'with-modals'
@ -43,8 +40,6 @@ class StatusesController < ApplicationController
end
format.json do
mark_cacheable! unless @stream_entry.hidden?
render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
end
@ -53,8 +48,6 @@ class StatusesController < ApplicationController
end
def activity
skip_session!
render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
end
@ -64,7 +57,6 @@ class StatusesController < ApplicationController
use_pack 'embed'
raise ActiveRecord::RecordNotFound if @status.hidden?
skip_session!
expires_in 180, public: true
response.headers['X-Frame-Options'] = 'ALLOWALL'
@autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay])
@ -73,8 +65,6 @@ class StatusesController < ApplicationController
end
def replies
skip_session!
render json: replies_collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,

View File

@ -17,19 +17,13 @@ class StreamEntriesController < ApplicationController
format.html do
use_pack 'public'
unless user_signed_in?
skip_session!
expires_in 5.minutes, public: true
end
expires_in 5.minutes, public: true unless @stream_entry.hidden?
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status'
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity)
end
format.atom do
unless @stream_entry.hidden?
skip_session!
expires_in 3.minutes, public: true
end
expires_in 3.minutes, public: true unless @stream_entry.hidden?
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
end
@ -57,7 +51,7 @@ class StreamEntriesController < ApplicationController
def set_stream_entry
@stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id])
@type = @stream_entry.activity_type.downcase
@type = 'status'
raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil?
authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only?

View File

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

View File

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

View File

@ -3,7 +3,7 @@
pack:
about:
admin: admin.js
auth:
auth: settings.js
common:
filename: common.js
stylesheet: true

View File

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

View File

@ -68,6 +68,14 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
});
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
export const ensureComposeIsVisible = (getState, routerHistory) => {
if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/statuses/new');
}
};
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@ -81,16 +89,14 @@ export function cycleElefriendCompose() {
};
};
export function replyCompose(status, router) {
export function replyCompose(status, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_REPLY,
status: status,
});
if (router && !getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
ensureComposeIsVisible(getState, routerHistory);
};
};
@ -106,29 +112,25 @@ export function resetCompose() {
};
};
export function mentionCompose(account, router) {
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_MENTION,
account: account,
});
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
ensureComposeIsVisible(getState, routerHistory);
};
};
export function directCompose(account, router) {
export function directCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_DIRECT,
account: account,
});
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
ensureComposeIsVisible(getState, routerHistory);
};
};
@ -136,7 +138,8 @@ export function submitCompose(routerHistory) {
return function (dispatch, getState) {
let status = getState().getIn(['compose', 'text'], '');
let media = getState().getIn(['compose', 'media_attachments']);
let spoilerText = getState().getIn(['compose', 'spoiler_text'], '');
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
if ((!status || !status.length) && media.size === 0) {
return;

View File

@ -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,
});
};

View File

@ -55,7 +55,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;

View File

@ -11,7 +11,9 @@ import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from 'flavours/glitch/util/html';
import { getFilters, regexFromFilters } from 'flavours/glitch/selectors';
import { getFiltersRegex } from 'flavours/glitch/selectors';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
import compareId from 'flavours/glitch/util/compare_id';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
@ -32,8 +34,9 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
@ -52,19 +55,28 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
}
};
export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
});
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
const filters = getFilters(getState(), { contextType: 'notifications' });
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
let filtered = false;
if (notification.type === 'mention') {
const regex = regexFromFilters(filters);
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
if (dropRegex && dropRegex.test(searchIndex)) {
return;
}
filtered = regex && regex.test(searchIndex);
}
@ -78,6 +90,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
usePendingItems: preferPendingItems,
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
});
@ -131,10 +144,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
: excludeTypesFromFilter(activeFilter),
};
if (!maxId && notifications.get('items').size > 0) {
params.since_id = notifications.getIn(['items', 0, 'id']);
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
const a = notifications.getIn(['pendingItems', 0, 'id']);
const b = notifications.getIn(['items', 0, 'id']);
if (a && b && compareId(a, b) > 0) {
params.since_id = a;
} else {
params.since_id = b || a;
}
}
const isLoadingRecent = !!params.since_id;
dispatch(expandNotificationsRequest(isLoadingMore));
api(getState).get('/api/v1/notifications', { params }).then(response => {
@ -143,7 +165,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
done();
}).catch(error => {
@ -160,13 +182,12 @@ export function expandNotificationsRequest(isLoadingMore) {
};
};
export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) {
return {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
accounts: notifications.map(item => item.account),
statuses: notifications.map(item => item.status).filter(status => !!status),
next,
usePendingItems,
skipLoading: !isLoadingMore,
};
};

View File

@ -48,7 +48,7 @@ export function submitSearch() {
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data));
dispatch(fetchSearchSuccess(response.data, value));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
@ -62,12 +62,11 @@ export function fetchSearchRequest() {
};
};
export function fetchSearchSuccess(results) {
export function fetchSearchSuccess(results, searchTerm) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
accounts: results.accounts,
statuses: results.statuses,
searchTerm,
};
};

View File

@ -2,6 +2,7 @@ import api from 'flavours/glitch/util/api';
import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { ensureComposeIsVisible } from './compose';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -80,7 +81,7 @@ export function redraft(status, raw_text, content_type) {
};
};
export function deleteStatus(id, router, withRedraft = false) {
export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]);
@ -97,9 +98,7 @@ export function deleteStatus(id, router, withRedraft = false) {
if (withRedraft) {
dispatch(redraft(status, response.data.text, response.data.content_type));
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
dispatch(deleteStatusFail(id, error));

View File

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

View File

@ -1,6 +1,8 @@
import { importFetchedStatus, importFetchedStatuses } from './importer';
import api, { getLinks } from 'flavours/glitch/util/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'flavours/glitch/util/compare_id';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
timeline,
});
export function updateTimeline(timeline, status, accept) {
return dispatch => {
@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) {
type: TIMELINE_UPDATE,
timeline,
status,
usePendingItems: preferPendingItems,
});
};
};
@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
return;
}
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
params.since_id = timeline.getIn(['items', 0]);
if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) {
const a = timeline.getIn(['pendingItems', 0]);
const b = timeline.getIn(['items', 0]);
if (a && b && compareId(a, b) > 0) {
params.since_id = a;
} else {
params.since_id = b || a;
}
}
const isLoadingRecent = !!params.since_id;
@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
@ -117,7 +132,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) {
};
};
export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) {
export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) {
return {
type: TIMELINE_EXPAND_SUCCESS,
timeline,
@ -125,6 +140,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
next,
partial,
isLoadingRecent,
usePendingItems,
skipLoading: !isLoadingMore,
};
};
@ -153,9 +169,8 @@ export function connectTimeline(timeline) {
};
};
export function disconnectTimeline(timeline) {
return {
type: TIMELINE_DISCONNECT,
timeline,
};
};
export const disconnectTimeline = timeline => ({
type: TIMELINE_DISCONNECT,
timeline,
usePendingItems: preferPendingItems,
});

View File

@ -49,7 +49,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
autoFocus: PropTypes.bool,
className: PropTypes.string,
id: PropTypes.string,
searchTokens: PropTypes.list,
searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number,
};

View File

@ -138,8 +138,11 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.setState({ suggestionsHidden: true, focused: false });
}
onFocus = () => {
onFocus = (e) => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus(e);
}
}
onSuggestionClick = (e) => {
@ -189,7 +192,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
@ -197,34 +200,39 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
style.direction = 'rtl';
}
return (
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
</div>,
];
}
}

View File

@ -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>
);
}
}

View File

@ -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,
};

View File

@ -50,43 +50,43 @@ export default class ErrorBoundary extends React.PureComponent {
<h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
<p>
<FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
<ul>
<li>
<FormattedMessage
id='web_app_crash.report_issue'
defaultMessage='Report a bug in the {issuetracker}'
values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
/>
{ debugInfo !== '' && (
<details>
<summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
<textarea
className='web_app_crash-stacktrace'
value={debugInfo}
rows='10'
readOnly
/>
</details>
)}
</li>
<li>
<FormattedMessage
id='web_app_crash.reload_page'
defaultMessage='{reload} the current page'
values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
/>
</li>
{ preferencesLink !== undefined && (
<li>
<FormattedMessage
id='web_app_crash.change_your_settings'
defaultMessage='Change your {settings}'
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
/>
</li>
)}
</ul>
</p>
<ul>
<li>
<FormattedMessage
id='web_app_crash.report_issue'
defaultMessage='Report a bug in the {issuetracker}'
values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
/>
{ debugInfo !== '' && (
<details>
<summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
<textarea
className='web_app_crash-stacktrace'
value={debugInfo}
rows='10'
readOnly
/>
</details>
)}
</li>
<li>
<FormattedMessage
id='web_app_crash.reload_page'
defaultMessage='{reload} the current page'
values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
/>
</li>
{ preferencesLink !== undefined && (
<li>
<FormattedMessage
id='web_app_crash.change_your_settings'
defaultMessage='Change your {settings}'
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
/>
</li>
)}
</ul>
</div>
</div>
);

View File

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

View File

@ -1,10 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry';
export default class IntersectionObserverArticle extends ImmutablePureComponent {
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
export default class IntersectionObserverArticle extends React.Component {
static propTypes = {
intersectionObserverWrapper: PropTypes.object.isRequired,
@ -22,20 +24,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
}
shouldComponentUpdate (nextProps, nextState) {
if (!nextState.isIntersecting && nextState.isHidden) {
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
// that either "isIntersecting" or "isHidden" matter, and then they're
// the only things that matter (and updated ARIA attributes).
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
// If we're going from a non-intersecting state to an intersecting state,
// (i.e. offscreen to onscreen), then we definitely need to re-render
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
if (!!isUnrendered !== !!willBeUnrendered) {
// If we're going from rendered to unrendered (or vice versa) then update
return true;
}
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
return super.shouldComponentUpdate(nextProps, nextState);
// If we are and remain hidden, diff based on props
if (isUnrendered) {
return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
}
// Else, assume the children have changed
return true;
}
componentDidMount () {
const { intersectionObserverWrapper, id } = this.props;
@ -119,7 +122,7 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
data-id={id}
tabIndex='0'
style={style}>
{children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || cachedHeight) })}
{children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })}
</article>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
export default class LoadPending extends React.PureComponent {
static propTypes = {
onClick: PropTypes.func,
count: PropTypes.number,
}
render() {
const { count } = this.props;
return (
<button className='load-more load-gap' onClick={this.props.onClick}>
<FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} />
</button>
);
}
}

View File

@ -6,7 +6,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from 'flavours/glitch/util/is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state';
import { decode } from 'blurhash';
const messages = defineMessages({
@ -101,6 +101,8 @@ class Item extends React.PureComponent {
}
_decode () {
if (!useBlurhash) return;
const hash = this.props.attachment.get('blurhash');
const pixels = decode(hash, 32, 32);
@ -177,7 +179,7 @@ class Item extends React.PureComponent {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a>
</div>
@ -257,7 +259,6 @@ export default class MediaGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
revealed: PropTypes.bool,
standalone: PropTypes.bool,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
@ -268,6 +269,8 @@ export default class MediaGallery extends React.PureComponent {
intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
};
static defaultProps = {
@ -275,13 +278,15 @@ export default class MediaGallery extends React.PureComponent {
};
state = {
visible: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed,
visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
width: this.props.defaultWidth,
};
componentWillReceiveProps (nextProps) {
if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) {
this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed });
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ visible: nextProps.visible });
}
}
@ -294,7 +299,11 @@ export default class MediaGallery extends React.PureComponent {
}
handleOpen = () => {
this.setState({ visible: !this.state.visible });
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {
this.setState({ visible: !this.state.visible });
}
}
handleClick = (index) => {

View File

@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4';
import PropTypes from 'prop-types';
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
import LoadMore from './load_more';
import LoadPending from './load_pending';
import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';
@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
onLoadMore: PropTypes.func,
onLoadPending: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
@ -28,6 +30,7 @@ export default class ScrollableList extends PureComponent {
isLoading: PropTypes.bool,
showLoading: PropTypes.bool,
hasMore: PropTypes.bool,
numPending: PropTypes.number,
prepend: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
@ -222,12 +225,18 @@ export default class ScrollableList extends PureComponent {
return !(location.state && location.state.mastodonModalOpen);
}
handleLoadPending = e => {
e.preventDefault();
this.props.onLoadPending();
}
render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null;
let scrollableArea = null;
if (showLoading) {
@ -248,6 +257,8 @@ export default class ScrollableList extends PureComponent {
<div role='feed' className='item-list'>
{prepend}
{loadPending}
{React.Children.map(this.props.children, (child, index) => (
<IntersectionObserverArticleContainer
key={child.key}

View File

@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
export default
class Spoilers extends React.PureComponent {
static propTypes = {
spoilerText: PropTypes.string,
children: PropTypes.node,
};
state = {
hidden: true,
}
handleSpoilerClick = () => {
this.setState({ hidden: !this.state.hidden });
}
render () {
const { spoilerText, children } = this.props;
const { hidden } = this.state;
const toggleText = hidden ?
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/> :
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/>;
return ([
<p className='spoiler__text'>
{spoilerText}
{' '}
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
{toggleText}
</button>
</p>,
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
{children}
</div>
]);
}
}

View File

@ -16,6 +16,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications
import classNames from 'classnames';
import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
import PollContainer from 'flavours/glitch/containers/poll_container';
import { displayMedia } from 'flavours/glitch/util/initial_state';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@ -38,8 +39,24 @@ export const textForScreenReader = (intl, status, rebloggedByText = false, expan
return values.join(', ');
};
@injectIntl
export default class Status extends ImmutablePureComponent {
export const defaultMediaVisibility = (status, settings) => {
if (!status) {
return undefined;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) {
return true;
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
}
export default @injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@ -49,6 +66,7 @@ export default class Status extends ImmutablePureComponent {
containerId: PropTypes.string,
id: PropTypes.string,
status: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
account: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
@ -66,6 +84,7 @@ export default class Status extends ImmutablePureComponent {
muted: PropTypes.bool,
collapse: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
prepend: PropTypes.string,
withDismiss: PropTypes.bool,
onMoveUp: PropTypes.func,
@ -76,12 +95,18 @@ export default class Status extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
onClick: PropTypes.func,
};
state = {
isCollapsed: false,
autoCollapsed: false,
isExpanded: undefined,
showMedia: undefined,
statusId: undefined,
revealBehindCW: undefined,
showCard: false,
forceFilter: undefined,
}
// Avoid checking props that are functions (and whose equality will always
@ -91,8 +116,6 @@ export default class Status extends ImmutablePureComponent {
'account',
'settings',
'prepend',
'boostModal',
'favouriteModal',
'muted',
'collapse',
'notification',
@ -103,6 +126,8 @@ export default class Status extends ImmutablePureComponent {
updateOnStates = [
'isExpanded',
'isCollapsed',
'showMedia',
'forceFilter',
]
// If our settings have changed to disable collapsed statuses, then we
@ -160,6 +185,20 @@ export default class Status extends ImmutablePureComponent {
}
}
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
update.statusId = nextProps.status.get('id');
updated = true;
}
if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
if (update.revealBehindCW) {
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
}
updated = true;
}
return updated ? update : null;
}
@ -219,28 +258,32 @@ export default class Status extends ImmutablePureComponent {
this.setState({ autoCollapsed: true });
}
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
// Hack to fix timeline jumps when a preview card is fetched
this.setState({
showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
});
}
// Hack to fix timeline jumps on second rendering when auto-collapsing
// or on subsequent rendering when a preview card has been fetched
getSnapshotBeforeUpdate (prevProps, prevState) {
if (this.props.getScrollPosition) {
if (!this.props.getScrollPosition) return null;
const { muted, hidden, status, settings } = this.props;
const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
if (doShowCard) this.setState({ showCard: true });
if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
return this.props.getScrollPosition();
} else {
return null;
}
}
// Hack to fix timeline jumps on second rendering when auto-collapsing
componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
if (this.state.autoCollapsed || (doShowCard && !this.didShowCard)) {
if (doShowCard) this.didShowCard = true;
if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
if (snapshot !== null && this.props.updateScrollBottom) {
if (this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top);
}
}
if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top);
}
}
@ -286,17 +329,21 @@ export default class Status extends ImmutablePureComponent {
const { status } = this.props;
const { isCollapsed } = this.state;
if (!router) return;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
if (isCollapsed) this.setCollapsed(false);
else if (e.shiftKey) {
this.setCollapsed(true);
document.getSelection().removeAllRanges();
} else if (this.props.onClick) {
this.props.onClick();
return;
} else {
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
let state = {...router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
router.history.push(destination, state);
@ -305,6 +352,10 @@ export default class Status extends ImmutablePureComponent {
}
}
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0) {
const id = e.currentTarget.getAttribute('data-id');
@ -374,6 +425,18 @@ export default class Status extends ImmutablePureComponent {
this.setCollapsed(!this.state.isCollapsed);
}
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
}
handleUnfilterClick = e => {
const { onUnfilter, status } = this.props;
onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false }));
}
handleFilterClick = () => {
this.setState({ forceFilter: true });
}
handleRef = c => {
this.node = c;
@ -399,6 +462,7 @@ export default class Status extends ImmutablePureComponent {
intl,
status,
account,
otherAccounts,
settings,
collapsed,
muted,
@ -408,6 +472,7 @@ export default class Status extends ImmutablePureComponent {
onOpenMedia,
notification,
hidden,
unread,
featured,
...other
} = this.props;
@ -431,7 +496,7 @@ export default class Status extends ImmutablePureComponent {
);
}
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
if ((status.get('filtered') || status.getIn(['reblog', 'filtered'])) && (this.state.forceFilter === true || settings.get('filtering_behavior') !== 'content_warning')) {
const minHandlers = this.props.muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
@ -441,6 +506,12 @@ export default class Status extends ImmutablePureComponent {
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
{settings.get('filtering_behavior') !== 'upstream' && ' '}
{settings.get('filtering_behavior') !== 'upstream' && (
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' />
</button>
)}
</div>
</HotKeys>
);
@ -472,16 +543,16 @@ export default class Status extends ImmutablePureComponent {
media={status.get('media_attachments')}
/>
);
} else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
const video = status.getIn(['media_attachments', 0]);
} else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) {
const attachment = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
preview={attachment.get('preview_url')}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={attachment.get('description')}
inline
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
@ -490,11 +561,12 @@ export default class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
width={this.props.cachedMediaWidth}
cacheWidth={this.props.cacheMediaWidth}
revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>)}
</Bundle>
);
mediaIcon = 'video-camera';
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
} else { // Media type is 'image' or 'gifv'
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
@ -508,7 +580,8 @@ export default class Status extends ImmutablePureComponent {
onOpenMedia={this.props.onOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
@ -566,12 +639,14 @@ export default class Status extends ImmutablePureComponent {
toggleSpoiler: this.handleExpandedToggle,
bookmark: this.handleHotkeyBookmark,
toggleCollapse: this.handleHotkeyCollapse,
toggleSensitive: this.handleHotkeyToggleSensitive,
};
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
collapsed: isCollapsed,
'has-background': isCollapsed && background,
'status__wrapper-reply': !!status.get('in_reply_to_id'),
read: unread === false,
muted,
}, 'focusable');
@ -602,6 +677,7 @@ export default class Status extends ImmutablePureComponent {
friend={account}
collapsed={isCollapsed}
parseClick={parseClick}
otherAccounts={otherAccounts}
/>
) : null}
</span>
@ -611,6 +687,7 @@ export default class Status extends ImmutablePureComponent {
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isCollapsed}
setCollapsed={setCollapsed}
directMessage={!!otherAccounts}
/>
</header>
<StatusContent
@ -628,6 +705,8 @@ export default class Status extends ImmutablePureComponent {
status={status}
account={status.get('account')}
showReplyCount={settings.get('show_reply_count')}
directMessage={!!otherAccounts}
onFilter={this.handleFilterClick}
/>
) : null}
{notification ? (

View File

@ -35,6 +35,7 @@ const messages = defineMessages({
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
});
const obfuscatedCount = count => {
@ -69,8 +70,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
onBookmark: PropTypes.func,
onFilter: PropTypes.func,
withDismiss: PropTypes.bool,
showReplyCount: PropTypes.bool,
directMessage: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -151,8 +154,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
handleOpen = () => {
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
if (state.mastodonModalOpen) {
this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
} else {
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
}
}
handleEmbed = () => {
@ -186,8 +193,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
}
handleFilterClick = () => {
this.props.onFilter();
}
render () {
const { status, intl, withDismiss, showReplyCount } = this.props;
const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
@ -258,6 +269,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
const filterButton = status.get('filtered') && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
);
let replyButton = (
<IconButton
className='status__action-bar-button'
@ -278,14 +293,16 @@ export default class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
{replyButton}
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div>
{!directMessage && [
<IconButton key='reblog-button' className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
<IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
shareButton,
<IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
filterButton,
<div key='dropdown-button' className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div>,
]}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>

View File

@ -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>
);
}
}
}

View File

@ -12,6 +12,13 @@ import VisibilityIcon from './status_visibility_icon';
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' },
previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' },
pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' },
poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' },
video: { id: 'status.has_video', defaultMessage: 'Features attached videos' },
audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' },
localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' },
});
@injectIntl
@ -22,6 +29,7 @@ export default class StatusIcons extends React.PureComponent {
mediaIcon: PropTypes.string,
collapsible: PropTypes.bool,
collapsed: PropTypes.bool,
directMessage: PropTypes.bool,
setCollapsed: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -35,6 +43,23 @@ export default class StatusIcons extends React.PureComponent {
}
}
mediaIconTitleText () {
const { intl, mediaIcon } = this.props;
switch (mediaIcon) {
case 'link':
return intl.formatMessages(message.previewCard);
case 'picture-o':
return intl.formatMessage(messages.pictures);
case 'tasks':
return intl.formatMessage(messages.poll);
case 'video-camera':
return intl.formatMessage(messages.video);
case 'music':
return intl.formatMessage(messages.audio);
}
}
// Rendering.
render () {
const {
@ -42,6 +67,7 @@ export default class StatusIcons extends React.PureComponent {
mediaIcon,
collapsible,
collapsed,
directMessage,
intl,
} = this.props;
@ -51,17 +77,23 @@ export default class StatusIcons extends React.PureComponent {
<i
className={`fa fa-fw fa-comment status__reply-icon`}
aria-hidden='true'
title={intl.formatMessage(messages.inReplyTo)}
/>
) : null}
{status.get('local_only') &&
<i
className={`fa fa-fw fa-home`}
aria-hidden='true'
title={intl.formatMessage(messages.localOnly)}
/>}
{mediaIcon ? (
<i
className={`fa fa-fw fa-${mediaIcon} status__media-icon`}
aria-hidden='true'
title={this.mediaIconTitleText()}
/>
) : null}
{(
<VisibilityIcon visibility={status.get('visibility')} />
)}
{!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
{collapsible ? (
<IconButton
className='status__collapse-button'

View File

@ -1,7 +1,8 @@
import React from 'react';
import { connect } from 'react-redux';
import Status from 'flavours/glitch/components/status';
import { makeGetStatus } from 'flavours/glitch/selectors';
import { List as ImmutableList } from 'immutable';
import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors';
import {
replyCompose,
mentionCompose,
@ -25,7 +26,11 @@ import { openModal } from 'flavours/glitch/actions/modal';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
import { filterEditLink } from 'flavours/glitch/util/backend_links';
import { showAlertForError } from '../actions/alerts';
import AccountContainer from 'flavours/glitch/containers/account_container';
import Spoilers from '../components/spoilers';
import Icon from 'flavours/glitch/components/icon';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -36,6 +41,10 @@ const messages = defineMessages({
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
});
const makeMapStateToProps = () => {
@ -69,7 +78,7 @@ const makeMapStateToProps = () => {
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
onReply (status, router) {
dispatch((_, getState) => {
@ -96,11 +105,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onReblog (status, e) {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
} else if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
});
},
onBookmark (status) {
@ -184,6 +198,48 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onUnfilter (status, onConfirm) {
dispatch((_, getState) => {
let state = getState();
const serverSideType = toServerSideType(contextType);
const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
const searchIndex = status.get('search_index');
const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex));
dispatch(openModal('CONFIRM', {
message: [
<FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />,
<div className='filtered-status-info'>
<Spoilers spoilerText={intl.formatMessage(messages.author)}>
<AccountContainer id={status.getIn(['account', 'id'])} />
</Spoilers>
<Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}>
<ul>
{matchingFilters.map(filter => (
<li>
{filter.get('phrase')}
{!!filterEditLink && ' '}
{!!filterEditLink && (
<a
target='_blank'
className='filtered-status-edit-link'
title={intl.formatMessage(messages.editFilter)}
href={filterEditLink(filter.get('id'))}
>
<Icon icon='pencil' />
</a>
)}
</li>
))}
</ul>
</Spoilers>
</div>
],
confirm: intl.formatMessage(messages.unfilterConfirm),
onConfirm: onConfirm,
}));
});
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},

View File

@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent {
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>

View File

@ -55,6 +55,7 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
singleColumn: PropTypes.bool,
advancedOptions: ImmutablePropTypes.map,
layout: PropTypes.string,
@ -66,8 +67,6 @@ class ComposeForm extends ImmutablePureComponent {
preselectOnReply: PropTypes.bool,
onChangeSpoilerness: PropTypes.func,
onChangeVisibility: PropTypes.func,
onMount: PropTypes.func,
onUnmount: PropTypes.func,
onPaste: PropTypes.func,
onMediaDescriptionConfirm: PropTypes.func,
};
@ -141,6 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
}
}
setRef = c => {
this.composeForm = c;
};
// Inserts an emoji at the caret.
handleEmoji = (data) => {
const { textarea: { selectionStart } } = this;
@ -192,19 +195,12 @@ class ComposeForm extends ImmutablePureComponent {
}
}
// Tells our state the composer has been mounted.
componentDidMount () {
const { onMount } = this.props;
if (onMount) {
onMount();
}
}
// Tells our state the composer has been unmounted.
componentWillUnmount () {
const { onUnmount } = this.props;
if (onUnmount) {
onUnmount();
handleFocus = () => {
if (this.composeForm && !this.props.singleColumn) {
const { left, right } = this.composeForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.composeForm.scrollIntoView();
}
}
}
@ -227,6 +223,7 @@ class ComposeForm extends ImmutablePureComponent {
preselectDate,
text,
preselectOnReply,
singleColumn,
} = this.props;
let selectionEnd, selectionStart;
@ -246,7 +243,7 @@ class ComposeForm extends ImmutablePureComponent {
if (textarea) {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
textarea.scrollIntoView();
if (!singleColumn) textarea.scrollIntoView();
}
// Refocuses the textarea after submitting.
@ -307,7 +304,7 @@ class ComposeForm extends ImmutablePureComponent {
<ReplyIndicatorContainer />
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
@ -323,34 +320,32 @@ class ComposeForm extends ImmutablePureComponent {
searchTokens={[':']}
id='glitch.composer.spoiler.input'
className='spoiler-input__input'
autoFocus={false}
/>
</div>
<div className='composer--textarea'>
<TextareaIcons advancedOptions={advancedOptions} />
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
/>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
>
<EmojiPicker onPickEmoji={handleEmoji} />
</div>
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<OptionsContainer
advancedOptions={advancedOptions}

View File

@ -17,19 +17,21 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='drawer--account'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
<Avatar account={this.props.account} size={48} />
</Permalink>
<Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong>@{this.props.account.get('acct')}</strong>
</Permalink>
<div className='navigation-bar__profile'>
<Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong>@{this.props.account.get('acct')}</strong>
</Permalink>
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
</div>
</div>
);
};

View File

@ -232,7 +232,7 @@ class ComposerOptions extends ImmutablePureComponent {
const contentTypeItems = {
plain: {
icon: 'align-left',
icon: 'file-text',
name: 'text/plain',
text: <FormattedMessage {...messages.plain} />,
},

View File

@ -26,7 +26,7 @@ export default @injectIntl
class ReplyIndicator extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};

View File

@ -33,10 +33,10 @@ class SearchPopout extends React.PureComponent {
const { style } = this.props;
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
return (
<div style={{ ...style, position: 'absolute', width: 285 }}>
<div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul>
@ -60,6 +60,10 @@ class SearchPopout extends React.PureComponent {
export default @injectIntl
class Search extends React.PureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
value: PropTypes.string.isRequired,
submitted: PropTypes.bool,
@ -67,6 +71,7 @@ class Search extends React.PureComponent {
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
openInRoute: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -109,8 +114,10 @@ class Search extends React.PureComponent {
const { onSubmit } = this.props;
switch (e.key) {
case 'Enter':
if (onSubmit) {
onSubmit();
onSubmit();
if (this.props.openInRoute) {
this.context.router.history.push('/search');
}
break;
case 'Escape':
@ -121,14 +128,14 @@ class Search extends React.PureComponent {
render () {
const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const active = value.length > 0 || submitted;
const computedClass = classNames('drawer--search', { active });
const hasValue = value.length > 0 || submitted;
return (
<div className={computedClass}>
<div className='search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
@ -138,17 +145,19 @@ class Search extends React.PureComponent {
onBlur={this.handleBlur}
/>
</label>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='icon'
className='search__icon'
onClick={this.handleClear}
role='button'
tabIndex='0'
>
<Icon icon='search' />
<Icon icon='times-circle' />
<Icon icon='search' className={hasValue ? '' : 'active'} />
<Icon icon='times-circle' className={hasValue ? 'active' : ''} />
</div>
<Overlay show={expanded && !active} placement='bottom' target={this}>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div>

View File

@ -7,6 +7,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from 'flavours/glitch/components/hashtag';
import Icon from 'flavours/glitch/components/icon';
import { searchEnabled } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired,
searchTerm: PropTypes.string,
intl: PropTypes.object.isRequired,
};
@ -27,8 +29,8 @@ class SearchResults extends ImmutablePureComponent {
this.props.fetchSuggestions();
}
render() {
const { intl, results, suggestions, dismissSuggestion } = this.props;
render () {
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
if (results.isEmpty() && !suggestions.isEmpty()) {
return (
@ -51,6 +53,16 @@ class SearchResults extends ImmutablePureComponent {
</div>
</div>
);
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
statuses = (
<section>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<div className='search-results__info'>
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
</div>
</section>
);
}
let accounts, statuses, hashtags;

View File

@ -9,10 +9,8 @@ import {
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
mountCompose,
selectComposeSuggestion,
submitCompose,
unmountCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import {
@ -114,14 +112,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(changeComposeVisibility(value));
},
onMount() {
dispatch(mountCompose());
},
onUnmount() {
dispatch(unmountCompose());
},
onMediaDescriptionConfirm(routerHistory) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.missingDescriptionMessage),

View File

@ -16,7 +16,7 @@ function mapStateToProps (state) {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
hasPoll: !!poll,
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true),
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : true),
hasMedia: media && !!media.size,
allowPoll: !(media && !!media.size),
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),

View File

@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
suggestions: state.getIn(['suggestions', 'items']),
searchTerm: state.getIn(['search', 'searchTerm']),
});
const mapDispatchToProps = dispatch => ({

View File

@ -4,6 +4,7 @@ import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import SearchContainer from './containers/search_container';
@ -27,9 +28,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onClickElefriend () {
dispatch(cycleElefriendCompose());
},
onMount () {
dispatch(mountCompose());
},
onUnmount () {
dispatch(unmountCompose());
},
});
export default @connect(mapStateToProps)
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class Compose extends React.PureComponent {
static propTypes = {
@ -38,9 +47,27 @@ class Compose extends React.PureComponent {
isSearchPage: PropTypes.bool,
elefriend: PropTypes.number,
onClickElefriend: PropTypes.func,
onMount: PropTypes.func,
onUnmount: PropTypes.func,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { isSearchPage } = this.props;
if (!isSearchPage) {
this.props.onMount();
}
}
componentWillUnmount () {
const { isSearchPage } = this.props;
if (!isSearchPage) {
this.props.onUnmount();
}
}
render () {
const {
elefriend,
@ -61,12 +88,12 @@ class Compose extends React.PureComponent {
<div className='drawer__pager'>
{!isSearchPage && <div className='drawer__inner'>
<NavigationContainer />
<ComposeFormContainer />
{multiColumn && (
<div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div>
)}
<div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div>
</div>}
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>

View File

@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusContainer from 'flavours/glitch/containers/status_container';
export default class Conversation extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatusId: PropTypes.string,
unread:PropTypes.bool.isRequired,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
};
handleClick = () => {
if (!this.context.router) {
return;
}
const { lastStatusId, unread, markRead } = this.props;
if (unread) {
markRead();
}
this.context.router.history.push(`/statuses/${lastStatusId}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.conversationId);
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.conversationId);
}
render () {
const { accounts, lastStatusId, unread } = this.props;
if (lastStatusId === null) {
return null;
}
return (
<StatusContainer
id={lastStatusId}
unread={unread}
otherAccounts={accounts}
onMoveUp={this.handleHotkeyMoveUp}
onMoveDown={this.handleHotkeyMoveDown}
onClick={this.handleClick}
/>
);
}
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ConversationContainer from '../containers/conversation_container';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { debounce } from 'lodash';
export default class ConversationsList extends ImmutablePureComponent {
static propTypes = {
conversations: ImmutablePropTypes.list.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onLoadMore: PropTypes.func,
};
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex, true);
}
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex, false);
}
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
setRef = c => {
this.node = c;
}
handleLoadOlder = debounce(() => {
const last = this.props.conversations.last();
if (last && last.get('last_status')) {
this.props.onLoadMore(last.get('last_status'));
}
}, 300, { leading: true })
render () {
const { conversations, onLoadMore, ...other } = this.props;
return (
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
{conversations.map(item => (
<ConversationContainer
key={item.get('id')}
conversationId={item.get('id')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
))}
</ScrollableList>
);
}
}

View File

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import Conversation from '../components/conversation';
import { markConversationRead } from '../../../actions/conversations';
const mapStateToProps = (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatusId: conversation.get('last_status', null),
};
};
const mapDispatchToProps = (dispatch, { conversationId }) => ({
markRead: () => dispatch(markConversationRead(conversationId)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Conversation);

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import ConversationsList from '../components/conversations_list';
import { expandConversations } from 'flavours/glitch/actions/conversations';
const mapStateToProps = state => ({
conversations: state.getIn(['conversations', 'items']),
isLoading: state.getIn(['conversations', 'isLoading'], true),
hasMore: state.getIn(['conversations', 'hasMore'], false),
});
const mapDispatchToProps = dispatch => ({
onLoadMore: maxId => dispatch(expandConversations({ maxId })),
});
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);

View File

@ -5,10 +5,13 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from 'flavours/glitch/actions/streaming';
import { changeSetting } from 'flavours/glitch/actions/settings';
import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
@ -16,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
});
@connect(mapStateToProps)
@ -28,6 +32,7 @@ export default class DirectTimeline extends React.PureComponent {
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
conversationsMode: PropTypes.bool,
};
handlePin = () => {
@ -50,13 +55,32 @@ export default class DirectTimeline extends React.PureComponent {
}
componentDidMount () {
const { dispatch } = this.props;
const { dispatch, conversationsMode } = this.props;
dispatch(mountConversations());
if (conversationsMode) {
dispatch(expandConversations());
} else {
dispatch(expandDirectTimeline());
}
dispatch(expandDirectTimeline());
this.disconnect = dispatch(connectDirectStream());
}
componentDidUpdate(prevProps) {
const { dispatch, conversationsMode } = this.props;
if (prevProps.conversationsMode && !conversationsMode) {
dispatch(expandDirectTimeline());
} else if (!prevProps.conversationsMode && conversationsMode) {
dispatch(expandConversations());
}
}
componentWillUnmount () {
this.props.dispatch(unmountConversations());
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
@ -67,14 +91,49 @@ export default class DirectTimeline extends React.PureComponent {
this.column = c;
}
handleLoadMore = maxId => {
handleLoadMoreTimeline = maxId => {
this.props.dispatch(expandDirectTimeline({ maxId }));
}
handleLoadMoreConversations = maxId => {
this.props.dispatch(expandConversations({ maxId }));
}
handleTimelineClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], false));
}
handleConversationsClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], true));
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
const pinned = !!columnId;
let contents;
if (conversationsMode) {
contents = (
<ConversationsListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
);
} else {
contents = (
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMoreTimeline}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
);
}
return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
@ -90,13 +149,28 @@ export default class DirectTimeline extends React.PureComponent {
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
<div className='notification__filter-bar'>
<button
className={conversationsMode ? 'active' : ''}
onClick={this.handleConversationsClick}
>
<FormattedMessage
id='direct.conversations_mode'
defaultMessage='Conversations'
/>
</button>
<button
className={conversationsMode ? '' : 'active'}
onClick={this.handleTimelineClick}
>
<FormattedMessage
id='direct.timeline_mode'
defaultMessage='Timeline'
/>
</button>
</div>
{contents}
</Column>
);
}

View File

@ -11,7 +11,7 @@ import Overlay from 'react-overlays/lib/Overlay';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events';
import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -110,19 +110,6 @@ let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent {
static propTypes = {
@ -320,8 +307,23 @@ class EmojiPickerMenu extends React.PureComponent {
}
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
const categoriesSort = [
'recent',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker

View File

@ -59,7 +59,7 @@ export default class FollowRequests extends ImmutablePureComponent {
}
return (
<Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}>
<Column name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}>

View File

@ -8,12 +8,14 @@ import { openModal } from 'flavours/glitch/actions/modal';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, invitesEnabled, version } from 'flavours/glitch/util/initial_state';
import { me } from 'flavours/glitch/util/initial_state';
import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
import { List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { fetchLists } from 'flavours/glitch/actions/lists';
import { preferencesLink, profileLink, signOutLink } from 'flavours/glitch/util/backend_links';
import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links';
import NavigationBar from '../compose/components/navigation_bar';
import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -73,9 +75,15 @@ const badgeDisplay = (number, limit) => {
}
};
@connect(makeMapStateToProps, mapDispatchToProps)
@injectIntl
export default class GettingStarted extends ImmutablePureComponent {
const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
export default @connect(makeMapStateToProps, mapDispatchToProps)
@injectIntl
class GettingStarted extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
intl: PropTypes.object.isRequired,
@ -95,7 +103,12 @@ export default class GettingStarted extends ImmutablePureComponent {
}
componentDidMount () {
const { myAccount, fetchFollowRequests } = this.props;
const { myAccount, fetchFollowRequests, multiColumn } = this.props;
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
this.context.router.history.replace('/timelines/home');
return;
}
if (myAccount.get('locked')) {
fetchFollowRequests();
@ -135,7 +148,7 @@ export default class GettingStarted extends ImmutablePureComponent {
}
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
}
navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
@ -153,7 +166,8 @@ export default class GettingStarted extends ImmutablePureComponent {
<Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
<div className='scrollable optionally-scrollable'>
<div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
{!multiColumn && <NavigationBar account={myAccount} />}
{multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />}
{navItems}
<ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
{listItems}
@ -163,25 +177,7 @@ export default class GettingStarted extends ImmutablePureComponent {
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' />
</div>
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li>
</ul>
<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
values={{
github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
/>
</p>
</div>
<LinkFooter />
</div>
</Column>
);

View File

@ -71,6 +71,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
</tr>
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
{collapseEnabled && (
<tr>
<td><kbd>shift</kbd>+<kbd>x</kbd></td>

View File

@ -75,6 +75,23 @@ export default class ListTimeline extends React.PureComponent {
this.disconnect = dispatch(connectListStream(id));
}
componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
dispatch(fetchList(id));
dispatch(expandListTimeline(id));
this.disconnect = dispatch(connectListStream(id));
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();

View File

@ -13,6 +13,7 @@ const messages = defineMessages({
general: { id: 'settings.general', defaultMessage: 'General' },
compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box' },
content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' },
filters: { id: 'settings.filters', defaultMessage: 'Filters' },
collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
media: { id: 'settings.media', defaultMessage: 'Media' },
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
@ -60,27 +61,34 @@ export default class LocalSettingsNavigation extends React.PureComponent {
active={index === 3}
index={3}
onNavigate={onNavigate}
icon='angle-double-up'
title={intl.formatMessage(messages.collapsed)}
icon='filter'
title={intl.formatMessage(messages.filters)}
/>
<LocalSettingsNavigationItem
active={index === 4}
index={4}
onNavigate={onNavigate}
icon='angle-double-up'
title={intl.formatMessage(messages.collapsed)}
/>
<LocalSettingsNavigationItem
active={index === 5}
index={5}
onNavigate={onNavigate}
icon='image'
title={intl.formatMessage(messages.media)}
/>
<LocalSettingsNavigationItem
active={index === 5}
active={index === 6}
href={ preferencesLink }
index={5}
icon='sliders'
index={6}
icon='cog'
title={intl.formatMessage(messages.preferences)}
/>
<LocalSettingsNavigationItem
active={index === 6}
active={index === 7}
className='close'
index={6}
index={7}
onNavigate={onClose}
icon='times'
title={intl.formatMessage(messages.close)}

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