Merge upstream 'v2.9.2'

This commit is contained in:
Mike Barnes 2019-06-23 21:23:35 +10:00
commit f7f34cf315
544 changed files with 12857 additions and 7908 deletions

View file

@ -174,8 +174,7 @@ jobs:
steps: steps:
- *attach_workspace - *attach_workspace
- run: bundle exec i18n-tasks check-normalized - run: bundle exec i18n-tasks check-normalized
- run: bundle exec i18n-tasks unused - run: bundle exec i18n-tasks unused -l en
- run: bundle exec i18n-tasks missing -t plural
- run: bundle exec i18n-tasks check-consistent-interpolations - run: bundle exec i18n-tasks check-consistent-interpolations
workflows: workflows:

View file

@ -30,8 +30,8 @@ plugins:
channel: eslint-5 channel: eslint-5
rubocop: rubocop:
enabled: true enabled: true
channel: rubocop-0-54 channel: rubocop-0-71
scss-lint: sass-lint:
enabled: true enabled: true
exclude_patterns: exclude_patterns:
- spec/ - 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

@ -10,6 +10,7 @@ DB_NAME=postgres
DB_PASS= DB_PASS=
DB_PORT=5432 DB_PORT=5432
# Optional ElasticSearch configuration # Optional ElasticSearch configuration
# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set)
# ES_ENABLED=true # ES_ENABLED=true
# ES_HOST=es # ES_HOST=es
# ES_PORT=9200 # ES_PORT=9200

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: AllCops:
TargetRubyVersion: 2.3 TargetRubyVersion: 2.3
Exclude: Exclude:
@ -82,6 +85,9 @@ Rails/Exit:
- 'lib/mastodon/*' - 'lib/mastodon/*'
- 'lib/cli.rb' - 'lib/cli.rb'
Rails/HelperInstanceVariable:
Enabled: false
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
Enabled: false 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 # for specific ignore
!.svgo.yml !.svgo.yml
!sass-lint/**/*.yml

View file

@ -3,6 +3,132 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [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 ## [2.8.2] - 2019-05-05
### Added ### Added

View file

@ -18,9 +18,9 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://
## Translations ## Translations
You can submit translations via [Weblate](https://weblate.joinmastodon.org/). They are periodically merged into the codebase. You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase.
[![Mastodon translation statistics by language](https://weblate.joinmastodon.org/widgets/mastodon/-/multi-auto.svg)](https://weblate.joinmastodon.org/) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
## Pull requests ## Pull requests

View file

@ -7,7 +7,6 @@ SHELL ["bash", "-c"]
ENV NODE_VER="8.15.0" ENV NODE_VER="8.15.0"
RUN echo "Etc/UTC" > /etc/localtime && \ RUN echo "Etc/UTC" > /etc/localtime && \
apt update && \ apt update && \
apt -y dist-upgrade && \
apt -y install wget make gcc g++ python && \ apt -y install wget make gcc g++ python && \
cd ~ && \ cd ~ && \
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \ 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 && \ RUN apt update && \
echo "Etc/UTC" > /etc/localtime && \ echo "Etc/UTC" > /etc/localtime && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \ ln -s /opt/jemalloc/lib/* /usr/lib/ && \
apt -y dist-upgrade && \
apt install -y whois wget && \ apt install -y whois wget && \
addgroup --gid $GID mastodon && \ addgroup --gid $GID mastodon && \
useradd -m -u $UID -g $GID -d /opt/mastodon 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 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 \ RUN apt -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg \ libssl1.1 libpq5 imagemagick ffmpeg \
libicu60 libprotobuf10 libidn11 libyaml-0-2 \ libicu60 libprotobuf10 libidn11 libyaml-0-2 \
@ -95,7 +93,7 @@ RUN apt -y --no-install-recommends install \
ln -s /opt/mastodon /mastodon && \ ln -s /opt/mastodon /mastodon && \
gem install bundler && \ gem install bundler && \
rm -rf /var/cache && \ rm -rf /var/cache && \
rm -rf /var/lib/apt rm -rf /var/lib/apt/lists/*
# Add tini # Add tini
ENV TINI_VERSION="0.18.0" 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 echo "$TINI_SUM tini" | sha256sum -c -
RUN chmod +x /tini 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 --chown=mastodon:mastodon . /opt/mastodon
COPY --from=build-dep --chown=mastodon:mastodon /opt/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 RAILS_ENV="production"
ENV NODE_ENV="production" ENV NODE_ENV="production"

22
Gemfile
View file

@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2' gem 'pghero', '~> 2.2'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.36', require: false gem 'aws-sdk-s3', '~> 1.42', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -53,7 +53,7 @@ gem 'htmlentities', '~> 4.3'
gem 'http', '~> 3.3' gem 'http', '~> 3.3'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2' 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 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.1' gem 'kaminari', '~> 1.1'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
@ -62,7 +62,7 @@ gem 'nokogiri', '~> 1.10'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.7' gem 'oj', '~> 3.7'
gem 'ostatus2', '~> 2.0' 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 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0' gem 'pundit', '~> 2.0'
gem 'premailer-rails' gem 'premailer-rails'
@ -82,9 +82,9 @@ gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 4.1' gem 'simple_form', '~> 4.1'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.1.3' gem 'stoplight', '~> 2.1.3'
gem 'strong_migrations', '~> 0.3' gem 'strong_migrations', '~> 0.4'
gem 'tty-command', '~> 0.8', require: false 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 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2019' gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.0' gem 'webpacker', '~> 4.0'
@ -96,7 +96,7 @@ gem 'rdf-normalize', '~> 0.3'
group :development, :test do group :development, :test do
gem 'fabrication', '~> 2.20' gem 'fabrication', '~> 2.20'
gem 'fuubar', '~> 2.3' gem 'fuubar', '~> 2.4'
gem 'i18n-tasks', '~> 0.9', require: false gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.7' gem 'pry-byebug', '~> 3.7'
gem 'pry-rails', '~> 0.3' gem 'pry-rails', '~> 0.3'
@ -108,15 +108,15 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.18' gem 'capybara', '~> 3.24'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9' gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1' gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0' gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5' gem 'webmock', '~> 3.6'
gem 'parallel_tests', '~> 2.28' gem 'parallel_tests', '~> 2.29'
end end
group :development do group :development do
@ -128,10 +128,10 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.68', require: false gem 'rubocop', '~> 0.71', require: false
gem 'rubocop-rails', '~> 2.0', require: false
gem 'brakeman', '~> 4.5', require: false gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.58', require: false
gem 'capistrano', '~> 3.11' gem 'capistrano', '~> 3.11'
gem 'capistrano-rails', '~> 1.4' gem 'capistrano-rails', '~> 1.4'

View file

@ -75,20 +75,20 @@ GEM
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-eventstream (1.0.2) aws-eventstream (1.0.3)
aws-partitions (1.151.0) aws-partitions (1.175.0)
aws-sdk-core (3.48.4) aws-sdk-core (3.55.0)
aws-eventstream (~> 1.0, >= 1.0.2) aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0) aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.17.0) aws-sdk-kms (1.21.0)
aws-sdk-core (~> 3, >= 3.48.2) aws-sdk-core (~> 3, >= 3.53.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.36.1) aws-sdk-s3 (1.42.0)
aws-sdk-core (~> 3, >= 3.48.2) aws-sdk-core (~> 3, >= 3.53.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.0) aws-sigv4 (1.1.0)
aws-eventstream (~> 1.0, >= 1.0.2) aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.12) bcrypt (3.1.12)
@ -103,7 +103,7 @@ GEM
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.4.4) bootsnap (1.4.4)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.5.0) brakeman (4.5.1)
browser (2.5.3) browser (2.5.3)
builder (3.2.3) builder (3.2.3)
bullet (6.0.0) bullet (6.0.0)
@ -129,13 +129,13 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.18.0) capybara (3.24.0)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.8)
rack (>= 1.6.0) rack (>= 1.6.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (~> 1.2) regexp_parser (~> 1.5)
xpath (~> 3.2) xpath (~> 3.2)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
@ -231,7 +231,7 @@ GEM
fugit (1.1.6) fugit (1.1.6)
et-orbi (~> 1.1, >= 1.1.6) et-orbi (~> 1.1, >= 1.1.6)
raabro (~> 1.1) raabro (~> 1.1)
fuubar (2.3.2) fuubar (2.4.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
get_process_mem (0.2.3) get_process_mem (0.2.3)
@ -253,7 +253,7 @@ GEM
railties (>= 4.0.1) railties (>= 4.0.1)
hamster (3.0.0) hamster (3.0.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
hashdiff (0.3.7) hashdiff (0.4.0)
hashie (3.6.0) hashie (3.6.0)
heapy (0.1.4) heapy (0.1.4)
highline (2.0.1) highline (2.0.1)
@ -269,7 +269,7 @@ GEM
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.1.1) http-form_data (2.1.1)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httplog (1.2.2) httplog (1.3.1)
rack (>= 1.0) rack (>= 1.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.6.0) i18n (1.6.0)
@ -320,7 +320,7 @@ GEM
letter_opener (~> 1.0) letter_opener (~> 1.0)
railties (>= 3.2) railties (>= 3.2)
link_header (0.0.8) link_header (0.0.8)
lograge (0.11.0) lograge (0.11.2)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
@ -351,7 +351,7 @@ GEM
msgpack (1.2.10) msgpack (1.2.10)
multi_json (1.13.1) multi_json (1.13.1)
multipart-post (2.0.0) multipart-post (2.0.0)
necromancer (0.4.0) necromancer (0.5.0)
net-ldap (0.16.1) net-ldap (0.16.1)
net-scp (1.2.1) net-scp (1.2.1)
net-ssh (>= 2.6.5) net-ssh (>= 2.6.5)
@ -382,7 +382,7 @@ GEM
addressable (~> 2.5) addressable (~> 2.5)
http (~> 3.0) http (~> 3.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
ox (2.10.0) ox (2.11.0)
paperclip (6.0.0) paperclip (6.0.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -393,7 +393,7 @@ GEM
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.17.0) parallel (1.17.0)
parallel_tests (2.28.0) parallel_tests (2.29.0)
parallel parallel
parser (2.6.3.0) parser (2.6.3.0)
ast (~> 2.4.0) ast (~> 2.4.0)
@ -401,7 +401,7 @@ GEM
equatable (~> 0.5.0) equatable (~> 0.5.0)
tty-color (~> 0.4.0) tty-color (~> 0.4.0)
pg (1.1.4) pg (1.1.4)
pghero (2.2.0) pghero (2.2.1)
activerecord activerecord
pkg-config (1.3.7) pkg-config (1.3.7)
premailer (1.11.1) premailer (1.11.1)
@ -420,7 +420,7 @@ GEM
pry (~> 0.10) pry (~> 0.10)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (3.0.3) public_suffix (3.1.0)
puma (3.12.1) puma (3.12.1)
pundit (2.0.1) pundit (2.0.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -470,15 +470,12 @@ GEM
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (12.3.2) rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
rdf (3.0.9) rdf (3.0.9)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.3) rdf-normalize (0.3.3)
rdf (>= 2.2, < 4.0) rdf (>= 2.2, < 4.0)
redis (4.1.0) redis (4.1.2)
redis-actionpack (5.0.2) redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6) actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3) redis-rack (>= 1, < 3)
@ -497,7 +494,7 @@ GEM
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-store (1.5.0) redis-store (1.5.0)
redis (>= 2.2, < 5) redis (>= 2.2, < 5)
regexp_parser (1.4.0) regexp_parser (1.5.1)
request_store (1.4.1) request_store (1.4.1)
rack (>= 1.4) rack (>= 1.4)
responders (2.4.1) responders (2.4.1)
@ -527,31 +524,26 @@ GEM
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.8.0) rspec-support (3.8.0)
rubocop (0.68.1) rubocop (0.71.0)
jaro_winkler (~> 1.5.1) jaro_winkler (~> 1.5.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1) parser (>= 2.6)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.6) unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.0) rubocop-rails (2.0.1)
rack (>= 1.1)
rubocop (>= 0.70.0)
ruby-progressbar (1.10.1)
ruby-saml (1.9.0) ruby-saml (1.9.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
rufus-scheduler (3.5.2) rufus-scheduler (3.5.2)
fugit (~> 1.1, >= 1.1.5) fugit (~> 1.1, >= 1.1.5)
safe_yaml (1.0.4) safe_yaml (1.0.5)
sanitize (5.0.0) sanitize (5.0.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.8.0) nokogiri (>= 1.8.0)
nokogumbo (~> 2.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) sidekiq (5.2.7)
connection_pool (~> 2.2, >= 2.2.2) connection_pool (~> 2.2, >= 2.2.2)
rack (>= 1.5.0) rack (>= 1.5.0)
@ -593,8 +585,8 @@ GEM
stoplight (2.1.3) stoplight (2.1.3)
streamio-ffmpeg (3.0.2) streamio-ffmpeg (3.0.2)
multi_json (~> 1.8) multi_json (~> 1.8)
strong_migrations (0.3.1) strong_migrations (0.4.0)
activerecord (>= 3.2.0) activerecord (>= 5)
temple (0.8.1) temple (0.8.1)
terminal-table (1.8.0) terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
@ -603,22 +595,19 @@ GEM
thor (0.20.3) thor (0.20.3)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.9) tilt (2.0.9)
timers (4.2.0)
tty-color (0.4.3) tty-color (0.4.3)
tty-command (0.8.2) tty-command (0.8.2)
pastel (~> 0.7.0) pastel (~> 0.7.0)
tty-cursor (0.6.0) tty-cursor (0.7.0)
tty-prompt (0.18.1) tty-prompt (0.19.0)
necromancer (~> 0.4.0) necromancer (~> 0.5.0)
pastel (~> 0.7.0) pastel (~> 0.7.0)
timers (~> 4.0) tty-reader (~> 0.6.0)
tty-cursor (~> 0.6.0) tty-reader (0.6.0)
tty-reader (~> 0.5.0) tty-cursor (~> 0.7)
tty-reader (0.5.0) tty-screen (~> 0.7)
tty-cursor (~> 0.6.0)
tty-screen (~> 0.6.4)
wisper (~> 2.0.0) wisper (~> 2.0.0)
tty-screen (0.6.5) tty-screen (0.7.0)
twitter-text (1.14.7) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.5) tzinfo (1.2.5)
@ -628,15 +617,15 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.5) unf_ext (0.0.7.5)
unicode-display_width (1.5.0) unicode-display_width (1.6.0)
uniform_notifier (1.12.1) uniform_notifier (1.12.1)
warden (1.2.8) warden (1.2.8)
rack (>= 2.0.6) rack (>= 2.0.6)
webmock (3.5.1) webmock (3.6.0)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff hashdiff (>= 0.4.0, < 2.0.0)
webpacker (4.0.2) webpacker (4.0.7)
activesupport (>= 4.2) activesupport (>= 4.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 4.2) railties (>= 4.2)
@ -658,7 +647,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.6) active_record_query_trace (~> 1.6)
addressable (~> 2.6) addressable (~> 2.6)
annotate (~> 2.7) annotate (~> 2.7)
aws-sdk-s3 (~> 1.36) aws-sdk-s3 (~> 1.42)
better_errors (~> 2.5) better_errors (~> 2.5)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
blurhash (~> 0.1) blurhash (~> 0.1)
@ -671,7 +660,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4) capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.18) capybara (~> 3.24)
charlock_holmes (~> 0.7.6) charlock_holmes (~> 0.7.6)
chewy (~> 5.0) chewy (~> 5.0)
cld3 (~> 3.2.4) cld3 (~> 3.2.4)
@ -689,7 +678,7 @@ DEPENDENCIES
fastimage fastimage
fog-core (<= 2.1.0) fog-core (<= 2.1.0)
fog-openstack (~> 0.3) fog-openstack (~> 0.3)
fuubar (~> 2.3) fuubar (~> 2.4)
goldfinger (~> 2.1) goldfinger (~> 2.1)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
hiredis (~> 0.6) hiredis (~> 0.6)
@ -697,7 +686,7 @@ DEPENDENCIES
http (~> 3.3) http (~> 3.3)
http_accept_language (~> 2.1) http_accept_language (~> 2.1)
http_parser.rb (~> 0.6)! http_parser.rb (~> 0.6)!
httplog (~> 1.2) httplog (~> 1.3)
i18n-tasks (~> 0.9) i18n-tasks (~> 0.9)
idn-ruby idn-ruby
iso-639 iso-639
@ -721,10 +710,10 @@ DEPENDENCIES
omniauth-cas (~> 1.1) omniauth-cas (~> 1.1)
omniauth-saml (~> 1.10) omniauth-saml (~> 1.10)
ostatus2 (~> 2.0) ostatus2 (~> 2.0)
ox (~> 2.10) ox (~> 2.11)
paperclip (~> 6.0) paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6) paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.28) parallel_tests (~> 2.29)
pg (~> 1.1) pg (~> 1.1)
pghero (~> 2.2) pghero (~> 2.2)
pkg-config (~> 1.3) pkg-config (~> 1.3)
@ -748,9 +737,9 @@ DEPENDENCIES
rqrcode (~> 0.10) rqrcode (~> 0.10)
rspec-rails (~> 3.8) rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0) rspec-sidekiq (~> 3.0)
rubocop (~> 0.68) rubocop (~> 0.71)
rubocop-rails (~> 2.0)
sanitize (~> 5.0) sanitize (~> 5.0)
scss_lint (~> 0.58)
sidekiq (~> 5.2) sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0) sidekiq-scheduler (~> 3.0)
@ -762,13 +751,13 @@ DEPENDENCIES
stackprof stackprof
stoplight (~> 2.1.3) stoplight (~> 2.1.3)
streamio-ffmpeg (~> 3.0) streamio-ffmpeg (~> 3.0)
strong_migrations (~> 0.3) strong_migrations (~> 0.4)
thor (~> 0.20) thor (~> 0.20)
tty-command (~> 0.8) tty-command (~> 0.8)
tty-prompt (~> 0.18) tty-prompt (~> 0.19)
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2019) tzinfo-data (~> 1.2019)
webmock (~> 3.5) webmock (~> 3.6)
webpacker (~> 4.0) webpacker (~> 4.0)
webpush webpush

View file

@ -4,13 +4,13 @@
[![GitHub release](https://img.shields.io/github/release/tootsuite/mastodon.svg)][releases] [![GitHub release](https://img.shields.io/github/release/tootsuite/mastodon.svg)][releases]
[![Build Status](https://img.shields.io/circleci/project/github/tootsuite/mastodon.svg)][circleci] [![Build Status](https://img.shields.io/circleci/project/github/tootsuite/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate] [![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate]
[![Translation status](https://weblate.joinmastodon.org/widgets/mastodon/-/svg-badge.svg)][weblate] [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker] [![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
[releases]: https://github.com/tootsuite/mastodon/releases [releases]: https://github.com/tootsuite/mastodon/releases
[circleci]: https://circleci.com/gh/tootsuite/mastodon [circleci]: https://circleci.com/gh/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon [code_climate]: https://codeclimate.com/github/tootsuite/mastodon
[weblate]: https://weblate.joinmastodon.org/engage/mastodon/ [crowdin]: https://crowdin.com/project/mastodon
[docker]: https://hub.docker.com/r/tootsuite/mastodon/ [docker]: https://hub.docker.com/r/tootsuite/mastodon/
Mastodon is a **free, open-source social network server** based on ActivityPub. Follow friends and discover new ones. Publish anything you want: links, pictures, text, video. All servers of Mastodon are interoperable as a federated network, i.e. users on one server can seamlessly communicate with users from another one. This includes non-Mastodon software that also implements ActivityPub! Mastodon is a **free, open-source social network server** based on ActivityPub. Follow friends and discover new ones. Publish anything you want: links, pictures, text, video. All servers of Mastodon are interoperable as a federated network, i.e. users on one server can seamlessly communicate with users from another one. This includes non-Mastodon software that also implements ActivityPub!

View file

@ -46,8 +46,6 @@ class AccountsController < ApplicationController
end end
format.json do format.json do
mark_cacheable!
render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
end end

View file

@ -9,8 +9,6 @@ class ActivityPub::CollectionsController < Api::BaseController
before_action :set_cache_headers before_action :set_cache_headers
def show def show
skip_session!
render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new( ActiveModelSerializers::SerializableResource.new(
collection_presenter, collection_presenter,

View file

@ -10,10 +10,7 @@ class ActivityPub::OutboxesController < Api::BaseController
before_action :set_cache_headers before_action :set_cache_headers
def show def show
unless page_requested? expires_in 1.minute, public: true unless page_requested?
skip_session!
expires_in 1.minute, public: true
end
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end

View file

@ -48,13 +48,13 @@ module Admin
def approve def approve
authorize @account.user, :approve? authorize @account.user, :approve?
@account.user.approve! @account.user.approve!
redirect_to admin_accounts_path(pending: '1') redirect_to admin_pending_accounts_path
end end
def reject def reject
authorize @account.user, :reject? authorize @account.user, :reject?
SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) 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 end
def unsilence def unsilence
@ -127,6 +127,7 @@ module Admin
:by_domain, :by_domain,
:active, :active,
:pending, :pending,
:disabled,
:silenced, :silenced,
:suspended, :suspended,
:username, :username,

View file

@ -13,7 +13,7 @@ module Admin
authorize :domain_block, :create? authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params) @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) if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save @domain_block.save
@ -41,7 +41,7 @@ module Admin
def destroy def destroy
authorize @domain_block, :destroy? authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block, retroactive_unblock?) UnblockDomainService.new.call(@domain_block)
log_action :destroy, @domain_block log_action :destroy, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg') redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg')
end end
@ -53,11 +53,7 @@ module Admin
end end
def resource_params def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :retroactive) params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports)
end
def retroactive_unblock?
ActiveRecord::Type.lookup(:boolean).cast(resource_params[:retroactive])
end end
end end
end end

View file

@ -18,7 +18,7 @@ module Admin
@blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count @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) @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) @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 end
private 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

@ -44,7 +44,7 @@ class Api::V1::NotificationsController < Api::BaseController
end end
def browserable_account_notifications def browserable_account_notifications
current_account.notifications.browserable(exclude_types) current_account.notifications.browserable(exclude_types, from_account)
end end
def target_statuses_from_notifications def target_statuses_from_notifications
@ -81,6 +81,10 @@ class Api::V1::NotificationsController < Api::BaseController
val val
end end
def from_account
params[:account_id]
end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit, :exclude_types).permit(:limit, exclude_types: []).merge(core_params) params.slice(:limit, :exclude_types).permit(:limit, exclude_types: []).merge(core_params)
end end

View file

@ -1,13 +1,28 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::PollsController < Api::BaseController class Api::V1::PollsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show
before_action :set_poll
before_action :refresh_poll
respond_to :json respond_to :json
def show 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 render json: @poll, serializer: REST::PollSerializer, include_results: true
end 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 end

View file

@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params def data_params
return {} if params[:data].blank? 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
end end

View file

@ -65,7 +65,7 @@ class Api::V1::StatusesController < Api::BaseController
RemovalWorker.perform_async(@status.id) RemovalWorker.perform_async(@status.id)
render_empty render json: @status, serializer: REST::StatusSerializer, source_requested: true
end end
private private

View file

@ -22,6 +22,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
favourite: alerts_enabled, favourite: alerts_enabled,
reblog: alerts_enabled, reblog: alerts_enabled,
mention: alerts_enabled, mention: alerts_enabled,
poll: alerts_enabled,
}, },
} }
@ -57,6 +58,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end end
def data_params 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
end end

View file

@ -152,11 +152,6 @@ class ApplicationController < ActionController::Base
end end
def mark_cacheable! def mark_cacheable!
skip_session!
expires_in 0, public: true expires_in 0, public: true
end end
def skip_session!
request.session_options[:skip] = true
end
end end

View file

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

View file

@ -43,13 +43,7 @@ module SignatureVerification
return return
end end
account_stoplight = Stoplight("source:#{request.ip}") { account_from_key_id(signature_params['keyId']) } account = 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
if account.nil? if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @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? 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) } account = stoplight_wrap_request { 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
if account.nil? if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @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) def account_from_key_id(key_id)
if key_id.start_with?('acct:') 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) elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) 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 account
end end
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) def account_refresh_key(account)
return if account.local? || !account.activitypub? return if account.local? || !account.activitypub?
ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true) ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true)

View file

@ -1,10 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class CustomCssController < ApplicationController class CustomCssController < ApplicationController
skip_before_action :store_current_location
before_action :set_cache_headers before_action :set_cache_headers
def show def show
skip_session!
render plain: Setting.custom_css || '', content_type: 'text/css' render plain: Setting.custom_css || '', content_type: 'text/css'
end end
end end

View file

@ -7,8 +7,6 @@ class EmojisController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.json do format.json do
skip_session!
render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter) ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
end end

View file

@ -19,10 +19,7 @@ class FollowerAccountsController < ApplicationController
format.json do format.json do
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
if params[:page].blank? expires_in 3.minutes, public: true if params[:page].blank?
skip_session!
expires_in 3.minutes, public: true
end
render json: collection_presenter, render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer, serializer: ActivityPub::CollectionSerializer,

View file

@ -19,10 +19,7 @@ class FollowingAccountsController < ApplicationController
format.json do format.json do
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
if params[:page].blank? expires_in 3.minutes, public: true if params[:page].blank?
skip_session!
expires_in 3.minutes, public: true
end
render json: collection_presenter, render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer, serializer: ActivityPub::CollectionSerializer,

View file

@ -58,7 +58,7 @@ class HomeController < ApplicationController
if request.path.start_with?('/web') if request.path.start_with?('/web')
new_user_session_path new_user_session_path
elsif single_user_mode? elsif single_user_mode?
short_account_path(Account.local.where(suspended: false).first) short_account_path(Account.local.without_suspended.first)
else else
about_path about_path
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ManifestsController < ApplicationController class ManifestsController < ApplicationController
skip_before_action :store_current_location
def show def show
render json: InstancePresenter.new, serializer: ManifestSerializer render json: InstancePresenter.new, serializer: ManifestSerializer
end end

View file

@ -3,8 +3,12 @@
class MediaController < ApplicationController class MediaController < ApplicationController
include Authorization include Authorization
skip_before_action :store_current_location
before_action :set_media_attachment before_action :set_media_attachment
before_action :verify_permitted_status! before_action :verify_permitted_status!
before_action :check_playable, only: :player
before_action :allow_iframing, only: :player
content_security_policy only: :player do |p| content_security_policy only: :player do |p|
p.frame_ancestors(false) p.frame_ancestors(false)
@ -16,8 +20,6 @@ class MediaController < ApplicationController
def player def player
@body_classes = 'player' @body_classes = 'player'
response.headers['X-Frame-Options'] = 'ALLOWALL'
raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv?
end end
private private
@ -32,4 +34,12 @@ class MediaController < ApplicationController
# Reraise in order to get a 404 instead of a 403 error code # Reraise in order to get a 404 instead of a 403 error code
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound
end end
def check_playable
not_found unless @media_attachment.larger_media_format?
end
def allow_iframing
response.headers['X-Frame-Options'] = 'ALLOWALL'
end
end end

View file

@ -3,6 +3,8 @@
class MediaProxyController < ApplicationController class MediaProxyController < ApplicationController
include RoutingHelper include RoutingHelper
skip_before_action :store_current_location
def show def show
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@ -37,6 +39,6 @@ class MediaProxyController < ApplicationController
end end
def reject_media? def reject_media?
DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media? DomainBlock.reject_media?(@media_attachment.account.domain)
end end
end end

View file

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

View file

@ -1,32 +0,0 @@
# frozen_string_literal: true
class Settings::NotificationsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
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)
)
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

@ -12,7 +12,7 @@ class Settings::PreferencesController < Settings::BaseController
if current_user.update(user_params) if current_user.update(user_params)
I18n.locale = current_user.locale 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 else
render :show render :show
end end
@ -20,6 +20,10 @@ class Settings::PreferencesController < Settings::BaseController
private private
def after_update_redirect_path
settings_preferences_path
end
def user_settings def user_settings
UserSettingsDecorator.new(current_user) UserSettingsDecorator.new(current_user)
end end
@ -49,8 +53,9 @@ class Settings::PreferencesController < Settings::BaseController
:setting_hide_network, :setting_hide_network,
:setting_aggregate_reblogs, :setting_aggregate_reblogs,
:setting_show_application, :setting_show_application,
:setting_advanced_layout,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following) interactions: %i(must_be_follower must_be_following must_be_following_dm)
) )
end end
end end

View file

@ -27,7 +27,7 @@ class StatusesController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
mark_cacheable! unless user_signed_in? expires_in 10.seconds, public: true if current_account.nil?
@body_classes = 'with-modals' @body_classes = 'with-modals'
@ -38,8 +38,6 @@ class StatusesController < ApplicationController
end end
format.json do 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 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) ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
end end
@ -48,8 +46,6 @@ class StatusesController < ApplicationController
end end
def activity def activity
skip_session!
render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do 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) ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
end end
@ -58,7 +54,6 @@ class StatusesController < ApplicationController
def embed def embed
raise ActiveRecord::RecordNotFound if @status.hidden? raise ActiveRecord::RecordNotFound if @status.hidden?
skip_session!
expires_in 180, public: true expires_in 180, public: true
response.headers['X-Frame-Options'] = 'ALLOWALL' response.headers['X-Frame-Options'] = 'ALLOWALL'
@autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay]) @autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay])
@ -67,8 +62,6 @@ class StatusesController < ApplicationController
end end
def replies def replies
skip_session!
render json: replies_collection_presenter, render json: replies_collection_presenter,
serializer: ActivityPub::CollectionSerializer, serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter, adapter: ActivityPub::Adapter,

View file

@ -15,14 +15,13 @@ class StreamEntriesController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status' expires_in 5.minutes, public: true unless @stream_entry.hidden?
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity)
end end
format.atom do format.atom do
unless @stream_entry.hidden? expires_in 3.minutes, public: true unless @stream_entry.hidden?
skip_session!
expires_in 3.minutes, public: true
end
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
end end
@ -50,7 +49,7 @@ class StreamEntriesController < ApplicationController
def set_stream_entry def set_stream_entry
@stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) @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? raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil?
authorize @stream_entry.activity, :show? if @stream_entry.hidden? authorize @stream_entry.activity, :show? if @stream_entry.hidden?

View file

@ -16,24 +16,32 @@ module StreamEntriesHelper
if user_signed_in? if user_signed_in?
if account.id == current_user.account_id if account.id == current_user.account_id
link_to settings_profile_url, class: 'button logo-button' do 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 end
elsif current_account.following?(account) || current_account.requested?(account) 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 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 end
elsif !(account.memorial? || account.moved?) elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do 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
end end
elsif !(account.memorial? || account.moved?) elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do 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 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) def account_badge(account, all: false)
if account.bot? if account.bot?
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg"><symbol id="mastodon-svg-logo" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" /></symbol></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -8,6 +8,7 @@ const messages = defineMessages({
export const ALERT_SHOW = 'ALERT_SHOW'; export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR'; export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export function dismissAlert(alert) { export function dismissAlert(alert) {
return { return {
@ -36,7 +37,7 @@ export function showAlertForError(error) {
if (status === 404 || status === 410) { if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI // Skip these errors as they are reflected in the UI
return {}; return { type: ALERT_NOOP };
} }
let message = statusText; let message = statusText;

View file

@ -63,6 +63,14 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
}); });
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
export const ensureComposeIsVisible = (getState, routerHistory) => {
if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/statuses/new');
}
};
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -77,9 +85,7 @@ export function replyCompose(status, routerHistory) {
status: status, status: status,
}); });
if (!getState().getIn(['compose', 'mounted'])) { ensureComposeIsVisible(getState, routerHistory);
routerHistory.push('/statuses/new');
}
}; };
}; };
@ -102,9 +108,7 @@ export function mentionCompose(account, routerHistory) {
account: account, account: account,
}); });
if (!getState().getIn(['compose', 'mounted'])) { ensureComposeIsVisible(getState, routerHistory);
routerHistory.push('/statuses/new');
}
}; };
}; };
@ -115,9 +119,7 @@ export function directCompose(account, routerHistory) {
account: account, account: account,
}); });
if (!getState().getIn(['compose', 'mounted'])) { ensureComposeIsVisible(getState, routerHistory);
routerHistory.push('/statuses/new');
}
}; };
}; };
@ -383,7 +385,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
}; };
}; };
export function selectComposeSuggestion(position, token, suggestion) { export function selectComposeSuggestion(position, token, suggestion, path) {
return (dispatch, getState) => { return (dispatch, getState) => {
let completion, startPosition; let completion, startPosition;
@ -405,6 +407,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
position: startPosition, position: startPosition,
token, token,
completion, completion,
path,
}); });
}; };
}; };

View file

@ -48,9 +48,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; let filtered = false;
if (notification.type === 'mention') { if (notification.type === 'mention') {
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
const regex = regexFromFilters(filters); const regex = regexFromFilters(filters);
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
if (dropRegex && dropRegex.test(searchIndex)) {
return;
}
filtered = regex && regex.test(searchIndex); filtered = regex && regex.test(searchIndex);
} }

View file

@ -4,6 +4,7 @@ import { evictStatus } from '../storage/modifier';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer'; import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
import { ensureComposeIsVisible } from './compose';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -131,14 +132,15 @@ export function fetchStatusFail(id, error, skipLoading) {
}; };
}; };
export function redraft(status) { export function redraft(status, raw_text) {
return { return {
type: REDRAFT, type: REDRAFT,
status, status,
raw_text,
}; };
}; };
export function deleteStatus(id, router, withRedraft = false) { export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]); let status = getState().getIn(['statuses', id]);
@ -148,17 +150,14 @@ export function deleteStatus(id, router, withRedraft = false) {
dispatch(deleteStatusRequest(id)); dispatch(deleteStatusRequest(id));
api(getState).delete(`/api/v1/statuses/${id}`).then(() => { api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
evictStatus(id); evictStatus(id);
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status)); dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
} }
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));

View file

@ -0,0 +1,229 @@
import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export default class AutosuggestInput extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
autoFocus: PropTypes.bool,
className: PropTypes.string,
id: PropTypes.string,
searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number,
};
static defaultProps = {
autoFocus: true,
searchTokens: ImmutableList(['@', ':', '#']),
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || e.isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch(e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
}
onFocus = () => {
this.setState({ focused: true });
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.input.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setInput = (c) => {
this.input = c;
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={style}
aria-autocomplete='list'
id={id}
className={className}
maxLength={maxLength}
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
}
}

View file

@ -55,7 +55,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}; };
state = { state = {
suggestionsHidden: false, suggestionsHidden: true,
focused: false,
selectedSuggestion: 0, selectedSuggestion: 0,
lastToken: null, lastToken: null,
tokenStart: 0, tokenStart: 0,
@ -134,7 +135,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
onBlur = () => { onBlur = () => {
this.setState({ suggestionsHidden: true }); this.setState({ suggestionsHidden: true, focused: false });
}
onFocus = (e) => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus(e);
}
} }
onSuggestionClick = (e) => { onSuggestionClick = (e) => {
@ -145,7 +153,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false }); this.setState({ suggestionsHidden: false });
} }
} }
@ -184,7 +192,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
render () { 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 { suggestionsHidden } = this.state;
const style = { direction: 'ltr' }; const style = { direction: 'ltr' };
@ -192,7 +200,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
style.direction = 'rtl'; style.direction = 'rtl';
} }
return ( return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'> <div className='autosuggest-textarea'>
<label> <label>
<span style={{ display: 'none' }}>{placeholder}</span> <span style={{ display: 'none' }}>{placeholder}</span>
@ -207,18 +216,23 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
onPaste={this.onPaste} onPaste={this.onPaste}
style={style} style={style}
aria-autocomplete='list' aria-autocomplete='list'
/> />
</label> </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'}`}> <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)} {suggestions.map(this.renderSuggestion)}
</div> </div>
</div> </div>,
); ];
} }
} }

View file

@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, className }) => (
<i className='icon-with-badge'>
<Icon id={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

@ -157,7 +157,7 @@ class Item extends React.PureComponent {
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
return ( 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}%` }}> <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' /> <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a> </a>
</div> </div>
@ -244,6 +244,8 @@ class MediaGallery extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number, defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -251,19 +253,25 @@ class MediaGallery extends React.PureComponent {
}; };
state = { state = {
visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
width: this.props.defaultWidth, width: this.props.defaultWidth,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (!is(nextProps.media, this.props.media)) { if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: !nextProps.sensitive }); 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 });
} }
} }
handleOpen = () => { handleOpen = () => {
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {
this.setState({ visible: !this.state.visible }); this.setState({ visible: !this.state.visible });
} }
}
handleClick = (index) => { handleClick = (index) => {
this.props.onOpenMedia(this.props.media, index); this.props.onOpenMedia(this.props.media, index);

View file

@ -16,7 +16,7 @@ import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import PollContainer from 'mastodon/containers/poll_container'; import { displayMedia } from '../initial_state';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -39,6 +39,18 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => {
return values.join(', '); return values.join(', ');
}; };
export const defaultMediaVisibility = (status) => {
if (!status) {
return undefined;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
export default @injectIntl export default @injectIntl
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
@ -85,6 +97,11 @@ class Status extends ImmutablePureComponent {
'hidden', 'hidden',
]; ];
state = {
showMedia: defaultMediaVisibility(this.props.status),
statusId: undefined,
};
// Track height changes we know about to compensate scrolling // Track height changes we know about to compensate scrolling
componentDidMount () { componentDidMount () {
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
@ -98,11 +115,24 @@ class Status extends ImmutablePureComponent {
} }
} }
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
statusId: nextProps.status.get('id'),
};
} else {
return null;
}
}
// Compensate height changes // Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) { componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
if (doShowCard && !this.didShowCard) { if (doShowCard && !this.didShowCard) {
this.didShowCard = true; this.didShowCard = true;
if (snapshot !== null && this.props.updateScrollBottom) { if (snapshot !== null && this.props.updateScrollBottom) {
if (this.node && this.node.offsetTop < snapshot.top) { if (this.node && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top); this.props.updateScrollBottom(snapshot.height - snapshot.top);
@ -122,6 +152,10 @@ class Status extends ImmutablePureComponent {
} }
} }
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
}
handleClick = () => { handleClick = () => {
if (this.props.onClick) { if (this.props.onClick) {
this.props.onClick(); this.props.onClick();
@ -136,6 +170,22 @@ class Status extends ImmutablePureComponent {
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
} }
handleExpandClick = (e) => {
if (this.props.onClick) {
this.props.onClick();
return;
}
if (e.button === 0) {
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
}
}
handleAccountClick = (e) => { handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id'); const id = e.currentTarget.getAttribute('data-id');
@ -198,6 +248,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleHidden(this._properStatus()); this.props.onToggleHidden(this._properStatus());
} }
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
}
_properStatus () { _properStatus () {
const { status } = this.props; const { status } = this.props;
@ -271,9 +325,7 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog'); status = status.get('reblog');
} }
if (status.get('poll')) { if (status.get('media_attachments').size > 0) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) { if (this.props.muted) {
media = ( media = (
<AttachmentList <AttachmentList
@ -281,23 +333,25 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/> />
); );
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
const video = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => ( {Component => (
<Component <Component
preview={video.get('preview_url')} preview={attachment.get('preview_url')}
blurhash={video.get('blurhash')} blurhash={attachment.get('blurhash')}
src={video.get('url')} src={attachment.get('url')}
alt={video.get('description')} alt={attachment.get('description')}
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
height={110} height={110}
inline inline
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/> />
)} )}
</Bundle> </Bundle>
@ -313,6 +367,8 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth} defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/> />
)} )}
</Bundle> </Bundle>
@ -348,6 +404,7 @@ class Status extends ImmutablePureComponent {
moveUp: this.handleHotkeyMoveUp, moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown, moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden, toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
}; };
return ( return (
@ -356,6 +413,7 @@ class Status extends ImmutablePureComponent {
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>

View file

@ -5,6 +5,7 @@ import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink'; import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
@ -106,9 +107,13 @@ export default class StatusContent extends React.PureComponent {
const [ startX, startY ] = this.startXY; const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { let element = e.target;
while (element) {
if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') {
return; return;
} }
element = element.parentNode;
}
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
this.props.onClick(); this.props.onClick();
@ -191,6 +196,8 @@ export default class StatusContent extends React.PureComponent {
{mentionsPlaceholder} {mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} /> <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
@ -212,9 +219,13 @@ export default class StatusContent extends React.PureComponent {
output.push(readMoreButton); output.push(readMoreButton);
} }
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
return output; return output;
} else { } else {
return ( const output = [
<div <div
tabIndex='0' tabIndex='0'
ref={this.setRef} ref={this.setRef}
@ -222,8 +233,14 @@ export default class StatusContent extends React.PureComponent {
style={directionStyle} style={directionStyle}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
lang={status.get('language')} lang={status.get('language')}
/> />,
); ];
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
return output;
} }
} }

View file

@ -69,19 +69,19 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}, },
onModalReblog (status) { onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
dispatch(reblog(status));
}
},
onReblog (status, e) {
if (e.shiftKey || !boostModal) { if (e.shiftKey || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
} }
}
}, },
onFavourite (status) { onFavourite (status) {

View file

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon';
import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import { decode } from 'blurhash'; import { decode } from 'blurhash';
@ -88,8 +89,10 @@ export default class MediaItem extends ImmutablePureComponent {
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width; const height = width;
const status = attachment.get('status'); const status = attachment.get('status');
const title = status.get('spoiler_text') || attachment.get('description');
let thumbnail = ''; let thumbnail = '';
let icon;
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
// Skip // Skip
@ -131,11 +134,20 @@ export default class MediaItem extends ImmutablePureComponent {
); );
} }
if (!visible) {
icon = (
<span className='account-gallery__item__icons'>
<Icon id='eye-slash' />
</span>
);
}
return ( return (
<div className='account-gallery__item' style={{ width, height }}> <div className='account-gallery__item' style={{ width, height }}>
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick}> <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
{visible && thumbnail} {visible && thumbnail}
{!visible && icon}
</a> </a>
</div> </div>
); );

View file

@ -46,7 +46,7 @@ class ActionBar extends React.PureComponent {
return ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'> <div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer items={menu} icon='chevron-down' size={16} direction='right' />
</div> </div>
</div> </div>
); );

View file

@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -59,6 +60,7 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
anyMedia: PropTypes.bool, anyMedia: PropTypes.bool,
singleColumn: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -102,13 +104,23 @@ class ComposeForm extends ImmutablePureComponent {
} }
onSuggestionSelected = (tokenStart, token, value) => { onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value); this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
}
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
} }
handleChangeSpoilerText = (e) => { handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value); this.props.onChangeSpoilerText(e.target.value);
} }
handleFocus = () => {
if (this.composeForm && !this.props.singleColumn) {
this.composeForm.scrollIntoView();
}
}
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
// This statement does several things: // This statement does several things:
// - If we're beginning a reply, and, // - If we're beginning a reply, and,
@ -135,7 +147,7 @@ class ComposeForm extends ImmutablePureComponent {
this.autosuggestTextarea.textarea.focus(); this.autosuggestTextarea.textarea.focus();
} else if (this.props.spoiler !== prevProps.spoiler) { } else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) { if (this.props.spoiler) {
this.spoilerText.focus(); this.spoilerText.input.focus();
} else { } else {
this.autosuggestTextarea.textarea.focus(); this.autosuggestTextarea.textarea.focus();
} }
@ -150,6 +162,10 @@ class ComposeForm extends ImmutablePureComponent {
this.spoilerText = c; this.spoilerText = c;
} }
setRef = c => {
this.composeForm = c;
};
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const { text } = this.props; const { text } = this.props;
const position = this.autosuggestTextarea.textarea.selectionStart; const position = this.autosuggestTextarea.textarea.selectionStart;
@ -172,19 +188,29 @@ class ComposeForm extends ImmutablePureComponent {
} }
return ( return (
<div className='compose-form'> <div className='compose-form' ref={this.setRef}>
<WarningContainer /> <WarningContainer />
<ReplyIndicatorContainer /> <ReplyIndicatorContainer />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}> <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
<label> <AutosuggestInput
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> placeholder={intl.formatMessage(messages.spoiler_placeholder)}
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoilerText} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} /> value={this.props.spoilerText}
</label> onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
/>
</div> </div>
<div className='compose-form__autosuggest-wrapper'>
<AutosuggestTextarea <AutosuggestTextarea
ref={this.setAutosuggestTextarea} ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
@ -192,21 +218,20 @@ class ComposeForm extends ImmutablePureComponent {
value={this.props.text} value={this.props.text}
onChange={this.handleChange} onChange={this.handleChange}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste} onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)} autoFocus={!showSearch && !isMobile(window.innerWidth)}
/> >
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
<div className='compose-form__modifiers'> <div className='compose-form__modifiers'>
<UploadFormContainer /> <UploadFormContainer />
<PollFormContainer /> <PollFormContainer />
</div> </div>
</AutosuggestTextarea>
<div className='compose-form__buttons-wrapper'> <div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'> <div className='compose-form__buttons'>

View file

@ -20,7 +20,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> <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>
<div className='navigation-bar__profile'> <div className='navigation-bar__profile'>

View file

@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AutosuggestInput from 'mastodon/components/autosuggest_input';
import classNames from 'classnames'; import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
@ -27,6 +28,10 @@ class Option extends React.PureComponent {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired,
onToggleMultiple: PropTypes.func.isRequired, onToggleMultiple: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -38,12 +43,25 @@ class Option extends React.PureComponent {
this.props.onRemove(this.props.index); this.props.onRemove(this.props.index);
}; };
handleToggleMultiple = e => { handleToggleMultiple = e => {
this.props.onToggleMultiple(); this.props.onToggleMultiple();
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}; };
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
}
render () { render () {
const { isPollMultiple, title, index, intl } = this.props; const { isPollMultiple, title, index, intl } = this.props;
@ -57,12 +75,16 @@ class Option extends React.PureComponent {
tabIndex='0' tabIndex='0'
/> />
<input <AutosuggestInput
type='text'
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={25} maxLength={25}
value={title} value={title}
onChange={this.handleOptionTitleChange} onChange={this.handleOptionTitleChange}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
/> />
</label> </label>
@ -87,6 +109,10 @@ class PollForm extends ImmutablePureComponent {
onAddOption: PropTypes.func.isRequired, onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired, onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired, onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -103,7 +129,7 @@ class PollForm extends ImmutablePureComponent {
}; };
render () { render () {
const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props; const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) { if (!options) {
return null; return null;
@ -112,7 +138,7 @@ class PollForm extends ImmutablePureComponent {
return ( return (
<div className='compose-form__poll-wrapper'> <div className='compose-form__poll-wrapper'>
<ul> <ul>
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} />)} {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} {...other} />)}
</ul> </ul>
<div className='poll__footer'> <div className='poll__footer'>

View file

@ -129,7 +129,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting // It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by // size will be used to determine the coordinate of the menu by
// react-overlays // react-overlays
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}> <div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, zIndex: 2 }} role='listbox' ref={this.setRef}>
{items.map(item => ( {items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}> <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'> <div className='privacy-dropdown__option__icon'>

View file

@ -7,6 +7,7 @@ import DisplayName from '../../../components/display_name';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { isRtl } from '../../../rtl'; import { isRtl } from '../../../rtl';
import AttachmentList from 'mastodon/components/attachment_list';
const messages = defineMessages({ const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
@ -60,6 +61,13 @@ class ReplyIndicator extends ImmutablePureComponent {
</div> </div>
<div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} /> <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
)}
</div> </div>
); );
} }

View file

@ -21,7 +21,7 @@ class SearchPopout extends React.PureComponent {
const { style } = this.props; 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' />; 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 ( 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 }) }}> <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 }) => ( {({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
@ -47,6 +47,10 @@ class SearchPopout extends React.PureComponent {
export default @injectIntl export default @injectIntl
class Search extends React.PureComponent { class Search extends React.PureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = { static propTypes = {
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
submitted: PropTypes.bool, submitted: PropTypes.bool,
@ -54,6 +58,7 @@ class Search extends React.PureComponent {
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired, onShow: PropTypes.func.isRequired,
openInRoute: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -76,7 +81,12 @@ class Search extends React.PureComponent {
handleKeyUp = (e) => { handleKeyUp = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onSubmit(); this.props.onSubmit();
if (this.props.openInRoute) {
this.context.router.history.push('/search');
}
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} }

View file

@ -7,9 +7,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({ const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media (JPEG, PNG, GIF, WebM, MP4, MOV)' }, upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
}); });
const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
@ -60,9 +62,9 @@ class UploadButton extends ImmutablePureComponent {
return ( return (
<div className='compose-form__upload-button'> <div className='compose-form__upload-button'>
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> <IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label> <label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
<input <input
key={resetFileKey} key={resetFileKey}
ref={this.setRef} ref={this.setRef}

View file

@ -20,7 +20,7 @@ const mapStateToProps = state => ({
focusDate: state.getIn(['compose', 'focusDate']), focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']), caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']), preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']), isSubmitting: state.getIn(['compose', 'is_submitting']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
@ -45,8 +45,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(fetchComposeSuggestions(token)); dispatch(fetchComposeSuggestions(token));
}, },
onSuggestionSelected (position, token, suggestion) { onSuggestionSelected (position, token, suggestion, path) {
dispatch(selectComposeSuggestion(position, token, suggestion)); dispatch(selectComposeSuggestion(position, token, suggestion, path));
}, },
onChangeSpoilerText (checked) { onChangeSpoilerText (checked) {

View file

@ -1,8 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PollForm from '../components/poll_form'; import PollForm from '../components/poll_form';
import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose'; import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose';
import {
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from '../../../actions/compose';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
suggestions: state.getIn(['compose', 'suggestions']),
options: state.getIn(['compose', 'poll', 'options']), options: state.getIn(['compose', 'poll', 'options']),
expiresIn: state.getIn(['compose', 'poll', 'expires_in']), expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
isMultiple: state.getIn(['compose', 'poll', 'multiple']), isMultiple: state.getIn(['compose', 'poll', 'multiple']),
@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({
onChangeSettings(expiresIn, isMultiple) { onChangeSettings(expiresIn, isMultiple) {
dispatch(changePollSettings(expiresIn, isMultiple)); dispatch(changePollSettings(expiresIn, isMultiple));
}, },
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId, path) {
dispatch(selectComposeSuggestion(position, token, accountId, path));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(PollForm); export default connect(mapStateToProps, mapDispatchToProps)(PollForm);

View file

@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeComposeSensitivity } from 'mastodon/actions/compose'; import { changeComposeSensitivity } from 'mastodon/actions/compose';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({ const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@ -38,9 +37,19 @@ class SensitiveButton extends React.PureComponent {
return ( return (
<div className='compose-form__sensitive-button'> <div className='compose-form__sensitive-button'>
<button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
<Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> <input
</button> name='mark-sensitive'
type='checkbox'
checked={active}
onChange={onClick}
disabled={disabled}
/>
<span className={classNames('checkbox', { active })} />
<FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
</label>
</div> </div>
); );
} }

View file

@ -3,7 +3,7 @@ import UploadButton from '../components/upload_button';
import { uploadCompose } from '../../../actions/compose'; import { uploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
unavailable: state.getIn(['compose', 'poll']) !== null, unavailable: state.getIn(['compose', 'poll']) !== null,
resetFileKey: state.getIn(['compose', 'resetFileKey']), resetFileKey: state.getIn(['compose', 'resetFileKey']),
}); });

View file

@ -106,12 +106,12 @@ class Compose extends React.PureComponent {
<div className='drawer__pager'> <div className='drawer__pager'>
{!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}> {!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} /> <NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer /> <ComposeFormContainer />
{multiColumn && (
<div className='drawer__inner__mastodon'> <div className='drawer__inner__mastodon'>
<img alt='' draggable='false' src={mascot || elephantUIPlane} /> <img alt='' draggable='false' src={mascot || elephantUIPlane} />
</div> </div>
)}
</div>} </div>}
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}> <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>

View file

@ -56,7 +56,7 @@ class FollowRequests extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
return ( return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}> <Column icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='follow_requests' scrollKey='follow_requests'

View file

@ -7,12 +7,12 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, invitesEnabled, version, profile_directory, repository, source_url } from '../../initial_state'; import { me, profile_directory } from '../../initial_state';
import { fetchFollowRequests } from '../../actions/accounts'; import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { Link } from 'react-router-dom';
import NavigationBar from '../compose/components/navigation_bar'; import NavigationBar from '../compose/components/navigation_bar';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import LinkFooter from 'mastodon/features/ui/components/link_footer';
const messages = defineMessages({ const messages = defineMessages({
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
@ -55,10 +55,16 @@ const badgeDisplay = (number, limit) => {
} }
}; };
const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
export default @connect(mapStateToProps, mapDispatchToProps) export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl @injectIntl
class GettingStarted extends ImmutablePureComponent { class GettingStarted extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired, myAccount: ImmutablePropTypes.map.isRequired,
@ -70,7 +76,12 @@ class GettingStarted extends ImmutablePureComponent {
}; };
componentDidMount () { 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')) { if (myAccount.get('locked')) {
fetchFollowRequests(); fetchFollowRequests();
@ -123,7 +134,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 48*3; height += 48*3;
if (myAccount.get('locked')) { if (myAccount.get('locked')) {
navItems.push(<ColumnLink key={i++} icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); navItems.push(<ColumnLink key={i++} icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
height += 48; height += 48;
} }
@ -155,27 +166,7 @@ class GettingStarted extends ImmutablePureComponent {
{!multiColumn && <div className='flex-spacer' />} {!multiColumn && <div className='flex-spacer' />}
<div className='getting-started__footer'> <LinkFooter withHotkeys={multiColumn} />
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></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='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/>
</p>
</div>
</div> </div>
</Column> </Column>
); );

View file

@ -60,6 +60,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>x</kbd></td> <td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
</tr> </tr>
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr> <tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td> <td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td> <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>

View file

@ -75,6 +75,23 @@ class ListTimeline extends React.PureComponent {
this.disconnect = dispatch(connectListStream(id)); 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 () { componentWillUnmount () {
if (this.disconnect) { if (this.disconnect) {
this.disconnect(); this.disconnect();
@ -158,8 +175,6 @@ class ListTimeline extends React.PureComponent {
<Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button> </button>
</div> </div>
<hr />
</ColumnHeader> </ColumnHeader>
<StatusListContainer <StatusListContainer

View file

@ -65,11 +65,11 @@ class Lists extends ImmutablePureComponent {
<NewListForm /> <NewListForm />
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
<ScrollableList <ScrollableList
scrollKey='lists' scrollKey='lists'
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
> >
{lists.map(list => {lists.map(list =>
<ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />

View file

@ -0,0 +1,17 @@
import React from 'react';
import SearchContainer from 'mastodon/features/compose/containers/search_container';
import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container';
const Search = () => (
<div className='column search-page'>
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner darker'>
<SearchResultsContainer />
</div>
</div>
</div>
);
export default Search;

View file

@ -13,7 +13,6 @@ import Video from '../../video';
import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import PollContainer from 'mastodon/containers/poll_container';
export default class DetailedStatus extends ImmutablePureComponent { export default class DetailedStatus extends ImmutablePureComponent {
@ -30,6 +29,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
compact: PropTypes.bool, compact: PropTypes.bool,
showMedia: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func,
}; };
state = { state = {
@ -105,23 +106,23 @@ export default class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
} }
if (status.get('poll')) { if (status.get('media_attachments').size > 0) {
media = <PollContainer pollId={status.get('poll')} />; if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
} else if (status.get('media_attachments').size > 0) { const attachment = status.getIn(['media_attachments', 0]);
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = ( media = (
<Video <Video
preview={video.get('preview_url')} preview={attachment.get('preview_url')}
blurhash={video.get('blurhash')} blurhash={attachment.get('blurhash')}
src={video.get('url')} src={attachment.get('url')}
alt={video.get('description')} alt={attachment.get('description')}
width={300} width={300}
height={150} height={150}
inline inline
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/> />
); );
} else { } else {
@ -132,6 +133,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
height={300} height={300}
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/> />
); );
} }

View file

@ -43,7 +43,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import { boostModal, deleteModal } from '../../initial_state'; import { boostModal, deleteModal } from '../../initial_state';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import { textForScreenReader } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
const messages = defineMessages({ const messages = defineMessages({
@ -131,6 +131,8 @@ class Status extends ImmutablePureComponent {
state = { state = {
fullscreen: false, fullscreen: false,
showMedia: defaultMediaVisibility(this.props.status),
loadedStatusId: undefined,
}; };
componentWillMount () { componentWillMount () {
@ -146,6 +148,14 @@ class Status extends ImmutablePureComponent {
this._scrolledIntoView = false; this._scrolledIntoView = false;
this.props.dispatch(fetchStatus(nextProps.params.statusId)); this.props.dispatch(fetchStatus(nextProps.params.statusId));
} }
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
}
}
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
} }
handleFavouriteClick = (status) => { handleFavouriteClick = (status) => {
@ -312,6 +322,10 @@ class Status extends ImmutablePureComponent {
this.handleToggleHidden(this.props.status); this.handleToggleHidden(this.props.status);
} }
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
}
handleMoveUp = id => { handleMoveUp = id => {
const { status, ancestorsIds, descendantsIds } = this.props; const { status, ancestorsIds, descendantsIds } = this.props;
@ -432,6 +446,7 @@ class Status extends ImmutablePureComponent {
mention: this.handleHotkeyMention, mention: this.handleHotkeyMention,
openProfile: this.handleHotkeyOpenProfile, openProfile: this.handleHotkeyOpenProfile,
toggleHidden: this.handleHotkeyToggleHidden, toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
}; };
return ( return (
@ -455,6 +470,8 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia} onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden} onToggleHidden={this.handleToggleHidden}
domain={domain} domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
/> />
<ActionBar <ActionBar

View file

@ -9,8 +9,10 @@ import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AttachmentList from 'mastodon/components/attachment_list';
const messages = defineMessages({ const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
}); });
@ -51,6 +53,7 @@ class BoostModal extends ImmutablePureComponent {
render () { render () {
const { status, intl } = this.props; const { status, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
return ( return (
<div className='modal-root__modal boost-modal'> <div className='modal-root__modal boost-modal'>
@ -71,12 +74,19 @@ class BoostModal extends ImmutablePureComponent {
</div> </div>
<StatusContent status={status} /> <StatusContent status={status} />
{status.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
)}
</div> </div>
</div> </div>
<div className='boost-modal__action-bar'> <div className='boost-modal__action-bar'>
<div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div> <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div>
<Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} /> <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
</div> </div>
</div> </div>
); );

View file

@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import ReactSwipeableViews from 'react-swipeable-views'; import ReactSwipeableViews from 'react-swipeable-views';
import { links, getIndex, getLink } from './tabs_bar'; import TabsBar, { links, getIndex, getLink } from './tabs_bar';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
@ -14,6 +14,8 @@ import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll'; import { scrollRight } from '../../../scroll';
@ -139,7 +141,7 @@ class ColumnsArea extends ImmutablePureComponent {
<ColumnLoading title={title} icon={icon} />; <ColumnLoading title={title} icon={icon} />;
return ( return (
<div className='columns-area' key={index}> <div className='columns-area columns-area--mobile' key={index}>
{view} {view}
</div> </div>
); );
@ -163,17 +165,36 @@ class ColumnsArea extends ImmutablePureComponent {
if (singleColumn) { if (singleColumn) {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
return columnIndex !== -1 ? [ const content = columnIndex !== -1 ? (
<ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> <ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
{links.map(this.renderView)} {links.map(this.renderView)}
</ReactSwipeableViews>, </ReactSwipeableViews>
) : (
<div key='content' className='columns-area columns-area--mobile'>{children}</div>
);
floatingActionButton, return (
] : [ <div className='columns-area__panels'>
<div className='columns-area'>{children}</div>, <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
<div className='columns-area__panels__pane__inner'>
<ComposePanel />
</div>
</div>
floatingActionButton, <div className='columns-area__panels__main'>
]; <TabsBar key='tabs' />
{content}
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
<div className='columns-area__panels__pane__inner'>
<NavigationPanel />
</div>
</div>
{floatingActionButton}
</div>
);
} }
return ( return (

View file

@ -0,0 +1,16 @@
import React from 'react';
import SearchContainer from 'mastodon/features/compose/containers/search_container';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
import LinkFooter from './link_footer';
const ComposePanel = () => (
<div className='compose-panel'>
<SearchContainer openInRoute />
<NavigationContainer />
<ComposeFormContainer singleColumn />
<LinkFooter withHotkeys />
</div>
);
export default ComposePanel;

View file

@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import IconWithBadge from 'mastodon/components/icon_with_badge';
import { me } from 'mastodon/initial_state';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
locked: state.getIn(['accounts', me, 'locked']),
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
export default @withRouter
@connect(mapStateToProps)
class FollowRequestsNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
locked: PropTypes.bool,
count: PropTypes.number.isRequired,
};
componentDidMount () {
const { dispatch, locked } = this.props;
if (locked) {
dispatch(fetchFollowRequests());
}
}
render () {
const { locked, count } = this.props;
if (!locked || count === 0) {
return null;
}
return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
}
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
const LinkFooter = ({ withHotkeys }) => (
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></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='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/>
</p>
</div>
);
LinkFooter.propTypes = {
withHotkeys: PropTypes.bool,
};
export default LinkFooter;

View file

@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { fetchLists } from 'mastodon/actions/lists';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { NavLink, withRouter } from 'react-router-dom';
import Icon from 'mastodon/components/icon';
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
export default @withRouter
@connect(mapStateToProps)
class ListPanel extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchLists());
}
render () {
const { lists } = this.props;
if (!lists || lists.isEmpty()) {
return null;
}
return (
<div>
<hr />
{lists.map(list => (
<NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
))}
</div>
);
}
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';
import { profile_directory } from 'mastodon/initial_state';
import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link';
import ListPanel from './list_panel';
const NavigationPanel = () => (
<div className='navigation-panel'>
<NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
<FollowRequestsNavLink />
<NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
<NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
<ListPanel />
<hr />
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
{!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
</div>
);
export default withRouter(NavigationPanel);

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import IconWithBadge from 'mastodon/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.getIn(['notifications', 'unread']),
id: 'bell',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -5,16 +5,15 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { isUserTouching } from '../../../is_mobile'; import { isUserTouching } from '../../../is_mobile';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import NotificationsCounterIcon from './notifications_counter_icon';
export const links = [ export const links = [
<NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
<NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><Icon id='bell' fixedWidth /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
<NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
<NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
<NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
<NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
<NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
]; ];
export function getIndex (path) { export function getIndex (path) {

View file

@ -7,7 +7,6 @@ import { Redirect, withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import NotificationsContainer from './containers/notifications_container'; import NotificationsContainer from './containers/notifications_container';
import LoadingBarContainer from './containers/loading_bar_container'; import LoadingBarContainer from './containers/loading_bar_container';
import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container'; import ModalContainer from './containers/modal_container';
import { isMobile } from '../../is_mobile'; import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@ -45,8 +44,9 @@ import {
Mutes, Mutes,
PinnedStatuses, PinnedStatuses,
Lists, Lists,
Search,
} from './util/async-components'; } from './util/async-components';
import { me } from '../../initial_state'; import { me, forceSingleColumn } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewMediaState } from './components/media_modal';
import { previewState as previewVideoState } from './components/video_modal'; import { previewState as previewVideoState } from './components/video_modal';
@ -93,6 +93,7 @@ const keyMap = {
goToMuted: 'g m', goToMuted: 'g m',
goToRequests: 'g r', goToRequests: 'g r',
toggleHidden: 'x', toggleHidden: 'x',
toggleSensitive: 'h',
}; };
class SwitchingColumnsArea extends React.PureComponent { class SwitchingColumnsArea extends React.PureComponent {
@ -141,10 +142,11 @@ class SwitchingColumnsArea extends React.PureComponent {
render () { render () {
const { children } = this.props; const { children } = this.props;
const { mobile } = this.state; const { mobile } = this.state;
const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; const singleColumn = forceSingleColumn || mobile;
const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
return ( return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}> <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
<WrappedSwitch> <WrappedSwitch>
{redirect} {redirect}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
@ -160,7 +162,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} /> <WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
@ -479,8 +481,6 @@ class UI extends React.PureComponent {
return ( return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
<TabsBar />
<SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}> <SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}>
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>

View file

@ -129,3 +129,7 @@ export function ListEditor () {
export function ListAdder () { export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
} }
export function Search () {
return import(/*webpackChunkName: "features/search" */'../../search');
}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { fromJS } from 'immutable'; import { fromJS, is } from 'immutable';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
@ -102,6 +102,8 @@ class Video extends React.PureComponent {
detailed: PropTypes.bool, detailed: PropTypes.bool,
inline: PropTypes.bool, inline: PropTypes.bool,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
blurhash: PropTypes.string, blurhash: PropTypes.string,
link: PropTypes.node, link: PropTypes.node,
@ -117,7 +119,7 @@ class Video extends React.PureComponent {
fullscreen: false, fullscreen: false,
hovered: false, hovered: false,
muted: false, muted: false,
revealed: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
}; };
// hard coded in components.scss // hard coded in components.scss
@ -280,7 +282,16 @@ class Video extends React.PureComponent {
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
} }
componentDidUpdate (prevProps) { componentWillReceiveProps (nextProps) {
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ revealed: nextProps.visible });
}
}
componentDidUpdate (prevProps, prevState) {
if (prevState.revealed && !this.state.revealed && this.video) {
this.video.pause();
}
if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
this._decode(); this._decode();
} }
@ -316,12 +327,12 @@ class Video extends React.PureComponent {
} }
toggleReveal = () => { toggleReveal = () => {
if (this.state.revealed) { if (this.props.onToggleVisibility) {
this.video.pause(); this.props.onToggleVisibility();
} } else {
this.setState({ revealed: !this.state.revealed }); this.setState({ revealed: !this.state.revealed });
} }
}
handleLoadedData = () => { handleLoadedData = () => {
if (this.props.startTime) { if (this.props.startTime) {

View file

@ -19,5 +19,6 @@ export const version = getMeta('version');
export const mascot = getMeta('mascot'); export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory'); export const profile_directory = getMeta('profile_directory');
export const isStaff = getMeta('is_staff'); export const isStaff = getMeta('is_staff');
export const forceSingleColumn = !getMeta('advanced_layout');
export default initialState; export default initialState;

View file

@ -1,8 +1,8 @@
{ {
"account.add_or_remove_from_list": "أضيف/ي أو أحذف/ي من القائمة", "account.add_or_remove_from_list": "أضفه أو أزله من القائمة",
"account.badges.bot": "روبوت", "account.badges.bot": "روبوت",
"account.block": "حظر @{name}", "account.block": "حظر @{name}",
"account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}", "account.block_domain": "إخفاء كل شيئ قادم من اسم النطاق {domain}",
"account.blocked": "محظور", "account.blocked": "محظور",
"account.direct": "رسالة خاصة إلى @{name}", "account.direct": "رسالة خاصة إلى @{name}",
"account.domain_blocked": "النطاق مخفي", "account.domain_blocked": "النطاق مخفي",
@ -19,7 +19,7 @@
"account.locked_info": "تم تأمين خصوصية هذا الحساب عبر قفل. صاحب الحساب يُراجِع يدويا طلبات المتابَعة و الاشتراك بحسابه.", "account.locked_info": "تم تأمين خصوصية هذا الحساب عبر قفل. صاحب الحساب يُراجِع يدويا طلبات المتابَعة و الاشتراك بحسابه.",
"account.media": "وسائط", "account.media": "وسائط",
"account.mention": "أُذكُر/ي @{name}", "account.mention": "أُذكُر/ي @{name}",
"account.moved_to": "{name} إنتقل إلى :", "account.moved_to": "{name} انتقل إلى:",
"account.mute": "كتم @{name}", "account.mute": "كتم @{name}",
"account.mute_notifications": "كتم الإخطارات من @{name}", "account.mute_notifications": "كتم الإخطارات من @{name}",
"account.muted": "مكتوم", "account.muted": "مكتوم",
@ -36,7 +36,7 @@
"account.unmute": "إلغاء الكتم عن @{name}", "account.unmute": "إلغاء الكتم عن @{name}",
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}", "account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
"alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.", "alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
"alert.unexpected.title": "المعذرة !", "alert.unexpected.title": "المعذرة!",
"boost_modal.combo": "يمكنك/ي ضغط {combo} لتخطّي هذه في المرّة القادمة", "boost_modal.combo": "يمكنك/ي ضغط {combo} لتخطّي هذه في المرّة القادمة",
"bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
"bundle_column_error.retry": "إعادة المحاولة", "bundle_column_error.retry": "إعادة المحاولة",
@ -45,7 +45,7 @@
"bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", "bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
"bundle_modal_error.retry": "إعادة المحاولة", "bundle_modal_error.retry": "إعادة المحاولة",
"column.blocks": "الحسابات المحجوبة", "column.blocks": "الحسابات المحجوبة",
"column.community": "التَسَلْسُل الزَمني المحلي", "column.community": "الخيط العام المحلي",
"column.direct": "الرسائل المباشرة", "column.direct": "الرسائل المباشرة",
"column.domain_blocks": "النطاقات المخفية", "column.domain_blocks": "النطاقات المخفية",
"column.favourites": "المفضلة", "column.favourites": "المفضلة",
@ -66,7 +66,7 @@
"column_subheading.settings": "الإعدادات", "column_subheading.settings": "الإعدادات",
"community.column_settings.media_only": "الوسائط فقط", "community.column_settings.media_only": "الوسائط فقط",
"compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.", "compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.",
"compose_form.direct_message_warning_learn_more": "إقرأ المزيد", "compose_form.direct_message_warning_learn_more": "اقرأ المزيد",
"compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.", "compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
"compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.", "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
"compose_form.lock_disclaimer.lock": "مقفل", "compose_form.lock_disclaimer.lock": "مقفل",
@ -77,21 +77,22 @@
"compose_form.poll.remove_option": "إزالة هذا الخيار", "compose_form.poll.remove_option": "إزالة هذا الخيار",
"compose_form.publish": "بوّق", "compose_form.publish": "بوّق",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "تحديد الوسائط كحساسة",
"compose_form.sensitive.marked": "لقد تم تحديد هذه الصورة كحساسة", "compose_form.sensitive.marked": "لقد تم تحديد هذه الصورة كحساسة",
"compose_form.sensitive.unmarked": "لم يتم تحديد الصورة كحساسة", "compose_form.sensitive.unmarked": "لم يتم تحديد الصورة كحساسة",
"compose_form.spoiler.marked": "إنّ النص مخفي وراء تحذير", "compose_form.spoiler.marked": "إنّ النص مخفي وراء تحذير",
"compose_form.spoiler.unmarked": "النص غير مخفي", "compose_form.spoiler.unmarked": "النص غير مخفي",
"compose_form.spoiler_placeholder": "تنبيه عن المحتوى", "compose_form.spoiler_placeholder": "تنبيه عن المحتوى",
"confirmation_modal.cancel": "إلغاء", "confirmation_modal.cancel": "إلغاء",
"confirmations.block.block_and_report": "Block & Report", "confirmations.block.block_and_report": "احجبه وابلغ عنه",
"confirmations.block.confirm": "حجب", "confirmations.block.confirm": "حجب",
"confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟", "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟",
"confirmations.delete.confirm": "حذف", "confirmations.delete.confirm": "حذف",
"confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟", "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟",
"confirmations.delete_list.confirm": "Delete", "confirmations.delete_list.confirm": "احذف",
"confirmations.delete_list.message": "هل تود حقا حذف هذه القائمة ؟", "confirmations.delete_list.message": "هل تود حقا حذف هذه القائمة ؟",
"confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا", "confirmations.domain_block.confirm": "إخفاء اسم النطاق كاملا",
"confirmations.domain_block.message": "متأكد من أنك تود حظر إسم النطاق {domain} بالكامل ؟ في غالب الأحيان يُستَحسَن كتم أو حظر بعض الحسابات بدلا من حظر نطاق بالكامل.\nلن تتمكن مِن رؤية محتوى هذا النطاق لا على خيوطك العمومية و لا في إشعاراتك. سوف يتم كذلك إزالة كافة متابعيك المنتمين إلى هذا النطاق.", "confirmations.domain_block.message": "متأكد من أنك تود حظر اسم النطاق {domain} بالكامل ؟ في غالب الأحيان يُستَحسَن كتم أو حظر بعض الحسابات بدلا من حظر نطاق بالكامل.\nلن تتمكن مِن رؤية محتوى هذا النطاق لا على خيوطك العمومية و لا في إشعاراتك. سوف يتم كذلك إزالة كافة متابعيك المنتمين إلى هذا النطاق.",
"confirmations.mute.confirm": "أكتم", "confirmations.mute.confirm": "أكتم",
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
"confirmations.redraft.confirm": "إزالة و إعادة الصياغة", "confirmations.redraft.confirm": "إزالة و إعادة الصياغة",
@ -101,17 +102,17 @@
"confirmations.unfollow.confirm": "إلغاء المتابعة", "confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟", "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"embed.instructions": "يمكنكم إدماج هذا المنشور على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.", "embed.instructions": "يمكنكم إدماج هذا المنشور على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
"embed.preview": "هكذا ما سوف يبدو عليه :", "embed.preview": "هكذا ما سوف يبدو عليه:",
"emoji_button.activity": "الأنشطة", "emoji_button.activity": "الأنشطة",
"emoji_button.custom": "مخصص", "emoji_button.custom": "مخصص",
"emoji_button.flags": "الأعلام", "emoji_button.flags": "الأعلام",
"emoji_button.food": "الطعام والشراب", "emoji_button.food": "الطعام والشراب",
"emoji_button.label": "أدرج إيموجي", "emoji_button.label": "أدرج إيموجي",
"emoji_button.nature": "الطبيعة", "emoji_button.nature": "الطبيعة",
"emoji_button.not_found": "لا إيموجو !! (╯°□°)╯︵ ┻━┻", "emoji_button.not_found": "لا إيموجو!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "أشياء", "emoji_button.objects": "أشياء",
"emoji_button.people": "الناس", "emoji_button.people": "الناس",
"emoji_button.recent": "الشائعة الإستخدام", "emoji_button.recent": "الشائعة الاستخدام",
"emoji_button.search": "ابحث...", "emoji_button.search": "ابحث...",
"emoji_button.search_results": "نتائج البحث", "emoji_button.search_results": "نتائج البحث",
"emoji_button.symbols": "رموز", "emoji_button.symbols": "رموز",
@ -119,7 +120,7 @@
"empty_column.account_timeline": "ليس هناك تبويقات!", "empty_column.account_timeline": "ليس هناك تبويقات!",
"empty_column.account_unavailable": "الملف الشخصي غير متوفر", "empty_column.account_unavailable": "الملف الشخصي غير متوفر",
"empty_column.blocks": "لم تقم بحظر أي مستخدِم بعد.", "empty_column.blocks": "لم تقم بحظر أي مستخدِم بعد.",
"empty_column.community": "الخط الزمني المحلي فارغ. أكتب شيئا ما للعامة كبداية !", "empty_column.community": "الخط العام المحلي فارغ. أكتب شيئا ما للعامة كبداية!",
"empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.", "empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.",
"empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.", "empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.",
"empty_column.favourited_statuses": "ليس لديك أية تبويقات مفضلة بعد. عندما ستقوم بالإعجاب بواحد، سيظهر هنا.", "empty_column.favourited_statuses": "ليس لديك أية تبويقات مفضلة بعد. عندما ستقوم بالإعجاب بواحد، سيظهر هنا.",
@ -132,13 +133,13 @@
"empty_column.lists": "ليس عندك أية قائمة بعد. سوف تظهر قائمتك هنا إن قمت بإنشاء واحدة.", "empty_column.lists": "ليس عندك أية قائمة بعد. سوف تظهر قائمتك هنا إن قمت بإنشاء واحدة.",
"empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.", "empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.",
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.", "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
"empty_column.public": "لا يوجد أي شيء هنا ! قم بنشر شيء ما للعامة، أو إتبع المستخدمين الآخرين المتواجدين على الخوادم الأخرى لملء خيط المحادثات", "empty_column.public": "لا يوجد أي شيء هنا! قم بنشر شيء ما للعامة، أو اتبع المستخدمين الآخرين المتواجدين على الخوادم الأخرى لملء خيط المحادثات",
"follow_request.authorize": "ترخيص", "follow_request.authorize": "ترخيص",
"follow_request.reject": "رفض", "follow_request.reject": "رفض",
"getting_started.developers": "المُطوِّرون", "getting_started.developers": "المُطوِّرون",
"getting_started.directory": "دليل المستخدِمين والمستخدِمات", "getting_started.directory": "دليل المستخدِمين والمستخدِمات",
"getting_started.documentation": "الدليل", "getting_started.documentation": "الدليل",
"getting_started.heading": "إستعدّ للبدء", "getting_started.heading": "استعدّ للبدء",
"getting_started.invite": "دعوة أشخاص", "getting_started.invite": "دعوة أشخاص",
"getting_started.open_source_notice": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على جيت هب {github}.", "getting_started.open_source_notice": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على جيت هب {github}.",
"getting_started.security": "الأمان", "getting_started.security": "الأمان",
@ -155,16 +156,16 @@
"home.column_settings.basic": "أساسية", "home.column_settings.basic": "أساسية",
"home.column_settings.show_reblogs": "عرض الترقيات", "home.column_settings.show_reblogs": "عرض الترقيات",
"home.column_settings.show_replies": "عرض الردود", "home.column_settings.show_replies": "عرض الردود",
"intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "intervals.full.minutes": "{number, plural, one {# دقيقة} other {# دقائق}}",
"introduction.federation.action": "التالي", "introduction.federation.action": "التالي",
"introduction.federation.federated.headline": "Federated", "introduction.federation.federated.headline": "الفديرالي",
"introduction.federation.federated.text": "كافة المنشورات التي نُشِرت إلى العامة على الخوادم الأخرى للفديفرس سوف يتم عرضها على الخيط المُوحَّد.", "introduction.federation.federated.text": "كافة المنشورات التي نُشِرت إلى العامة على الخوادم الأخرى للفديفرس سوف يتم عرضها على الخيط المُوحَّد.",
"introduction.federation.home.headline": "Home", "introduction.federation.home.headline": "الرئيسي",
"introduction.federation.home.text": "سوف تُعرَض منشورات الأشخاص الذين تُتابِعهم على الخيط الرئيسي. بإمكانك متابعة أي حساب أيا كان الخادم الذي هو عليه!", "introduction.federation.home.text": "سوف تُعرَض منشورات الأشخاص الذين تُتابِعهم على الخيط الرئيسي. بإمكانك متابعة أي حساب أيا كان الخادم الذي هو عليه!",
"introduction.federation.local.headline": "Local", "introduction.federation.local.headline": "الخيط العام المحلي",
"introduction.federation.local.text": "المنشورات المُوجّهة للعامة على نفس الخادم الذي أنتم عليه ستظهر على الخيط الزمني المحلي.", "introduction.federation.local.text": "المنشورات المُوجّهة للعامة على نفس الخادم الذي أنتم عليه ستظهر على الخيط العام المحلي.",
"introduction.interactions.action": "إنهاء العرض التوضيحي!", "introduction.interactions.action": "إنهاء العرض التوضيحي!",
"introduction.interactions.favourite.headline": "الإضافة إلى المفضلة", "introduction.interactions.favourite.headline": "الإضافة إلى المفضلة",
"introduction.interactions.favourite.text": "يمكِنك إضافة أي تبويق إلى المفضلة و إعلام صاحبه أنك أعجِبت بذاك التبويق.", "introduction.interactions.favourite.text": "يمكِنك إضافة أي تبويق إلى المفضلة و إعلام صاحبه أنك أعجِبت بذاك التبويق.",
@ -174,24 +175,24 @@
"introduction.interactions.reply.text": "يمكنكم الرد على تبويقاتكم و تبويقات الآخرين على شكل سلسلة محادثة.", "introduction.interactions.reply.text": "يمكنكم الرد على تبويقاتكم و تبويقات الآخرين على شكل سلسلة محادثة.",
"introduction.welcome.action": "هيا بنا!", "introduction.welcome.action": "هيا بنا!",
"introduction.welcome.headline": "الخطوات الأولى", "introduction.welcome.headline": "الخطوات الأولى",
"introduction.welcome.text": "مرحبا بكم على الفيديفيرس! بعد لحظات قليلة ، سيكون بمقدوركم بث رسائل والتحدث إلى أصدقائكم عبر تشكيلة واسعة من الخوادم المختلفة. هذا الخادم ، {domain} ، يستضيف ملفكم الشخصي ، لذا يجب تذكر اسمه جيدا.", "introduction.welcome.text": "مرحبا بكم على الفديفرس! بعد لحظات قليلة ، سيكون بمقدوركم بث رسائل والتحدث إلى أصدقائكم عبر تشكيلة واسعة من الخوادم المختلفة. هذا الخادم ، {domain} ، يستضيف ملفكم الشخصي ، لذا يجب تذكر اسمه جيدا.",
"keyboard_shortcuts.back": "للعودة", "keyboard_shortcuts.back": "للعودة",
"keyboard_shortcuts.blocked": "لفتح قائمة المستخدمين المحظورين", "keyboard_shortcuts.blocked": "لفتح قائمة المستخدمين المحظورين",
"keyboard_shortcuts.boost": "للترقية", "keyboard_shortcuts.boost": "للترقية",
"keyboard_shortcuts.column": "للتركيز على منشور على أحد الأعمدة", "keyboard_shortcuts.column": "للتركيز على منشور على أحد الأعمدة",
"keyboard_shortcuts.compose": "للتركيز على نافذة تحرير النصوص", "keyboard_shortcuts.compose": "للتركيز على نافذة تحرير النصوص",
"keyboard_shortcuts.description": "Description", "keyboard_shortcuts.description": "الوصف",
"keyboard_shortcuts.direct": "لفتح عمود الرسائل المباشرة", "keyboard_shortcuts.direct": "لفتح عمود الرسائل المباشرة",
"keyboard_shortcuts.down": "للإنتقال إلى أسفل القائمة", "keyboard_shortcuts.down": "للانتقال إلى أسفل القائمة",
"keyboard_shortcuts.enter": "to open status", "keyboard_shortcuts.enter": "لفتح المنشور",
"keyboard_shortcuts.favourite": "للإضافة إلى المفضلة", "keyboard_shortcuts.favourite": "للإضافة إلى المفضلة",
"keyboard_shortcuts.favourites": "لفتح قائمة المفضلات", "keyboard_shortcuts.favourites": "لفتح قائمة المفضلات",
"keyboard_shortcuts.federated": "لفتح الخيط الزمني الفديرالي", "keyboard_shortcuts.federated": "لفتح الخيط الزمني الفديرالي",
"keyboard_shortcuts.heading": "Keyboard Shortcuts", "keyboard_shortcuts.heading": "Keyboard Shortcuts",
"keyboard_shortcuts.home": "لفتح الخيط الرئيسي", "keyboard_shortcuts.home": "لفتح الخيط الرئيسي",
"keyboard_shortcuts.hotkey": "مفتاح الإختصار", "keyboard_shortcuts.hotkey": "مفتاح الاختصار",
"keyboard_shortcuts.legend": "لعرض هذا المفتاح", "keyboard_shortcuts.legend": "لعرض هذا المفتاح",
"keyboard_shortcuts.local": "لفتح الخيط الزمني المحلي", "keyboard_shortcuts.local": "لفتح الخيط العام المحلي",
"keyboard_shortcuts.mention": "لذِكر الناشر", "keyboard_shortcuts.mention": "لذِكر الناشر",
"keyboard_shortcuts.muted": "لفتح قائمة المستخدِمين المكتومين", "keyboard_shortcuts.muted": "لفتح قائمة المستخدِمين المكتومين",
"keyboard_shortcuts.my_profile": "لفتح ملفك الشخصي", "keyboard_shortcuts.my_profile": "لفتح ملفك الشخصي",
@ -203,22 +204,24 @@
"keyboard_shortcuts.search": "للتركيز على البحث", "keyboard_shortcuts.search": "للتركيز على البحث",
"keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"", "keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"",
"keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير", "keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
"keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
"keyboard_shortcuts.toot": "لتحرير تبويق جديد", "keyboard_shortcuts.toot": "لتحرير تبويق جديد",
"keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث", "keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
"keyboard_shortcuts.up": "للإنتقال إلى أعلى القائمة", "keyboard_shortcuts.up": "للانتقال إلى أعلى القائمة",
"lightbox.close": "إغلاق", "lightbox.close": "إغلاق",
"lightbox.next": "التالي", "lightbox.next": "التالي",
"lightbox.previous": "العودة", "lightbox.previous": "العودة",
"lightbox.view_context": "اعرض السياق",
"lists.account.add": "أضف إلى القائمة", "lists.account.add": "أضف إلى القائمة",
"lists.account.remove": "إحذف من القائمة", "lists.account.remove": "احذف من القائمة",
"lists.delete": "Delete list", "lists.delete": "احذف القائمة",
"lists.edit": "تعديل القائمة", "lists.edit": "تعديل القائمة",
"lists.edit.submit": "تعديل العنوان", "lists.edit.submit": "تعديل العنوان",
"lists.new.create": "إنشاء قائمة", "lists.new.create": "إنشاء قائمة",
"lists.new.title_placeholder": "عنوان القائمة الجديدة", "lists.new.title_placeholder": "عنوان القائمة الجديدة",
"lists.search": "إبحث في قائمة الحسابات التي تُتابِعها", "lists.search": "إبحث في قائمة الحسابات التي تُتابِعها",
"lists.subheading": "قوائمك", "lists.subheading": "قوائمك",
"loading_indicator.label": "تحميل ...", "loading_indicator.label": "تحميل...",
"media_gallery.toggle_visible": "عرض / إخفاء", "media_gallery.toggle_visible": "عرض / إخفاء",
"missing_indicator.label": "تعذر العثور عليه", "missing_indicator.label": "تعذر العثور عليه",
"missing_indicator.sublabel": "تعذر العثور على هذا المورد", "missing_indicator.sublabel": "تعذر العثور على هذا المورد",
@ -228,20 +231,22 @@
"navigation_bar.community_timeline": "الخيط العام المحلي", "navigation_bar.community_timeline": "الخيط العام المحلي",
"navigation_bar.compose": "تحرير تبويق جديد", "navigation_bar.compose": "تحرير تبويق جديد",
"navigation_bar.direct": "الرسائل المباشِرة", "navigation_bar.direct": "الرسائل المباشِرة",
"navigation_bar.discover": "إكتشف", "navigation_bar.discover": "اكتشف",
"navigation_bar.domain_blocks": "النطاقات المخفية", "navigation_bar.domain_blocks": "النطاقات المخفية",
"navigation_bar.edit_profile": "تعديل الملف الشخصي", "navigation_bar.edit_profile": "تعديل الملف الشخصي",
"navigation_bar.favourites": "المفضلة", "navigation_bar.favourites": "المفضلة",
"navigation_bar.filters": "الكلمات المكتومة", "navigation_bar.filters": "الكلمات المكتومة",
"navigation_bar.follow_requests": "طلبات المتابعة", "navigation_bar.follow_requests": "طلبات المتابعة",
"navigation_bar.follows_and_followers": "المتابِعين والمتابَعون",
"navigation_bar.info": "عن هذا الخادم", "navigation_bar.info": "عن هذا الخادم",
"navigation_bar.keyboard_shortcuts": "إختصارات لوحة المفاتيح", "navigation_bar.keyboard_shortcuts": "اختصارات لوحة المفاتيح",
"navigation_bar.lists": "القوائم", "navigation_bar.lists": "القوائم",
"navigation_bar.logout": "خروج", "navigation_bar.logout": "خروج",
"navigation_bar.mutes": "الحسابات المكتومة", "navigation_bar.mutes": "الحسابات المكتومة",
"navigation_bar.personal": "Personal", "navigation_bar.personal": "شخصي",
"navigation_bar.pins": "التبويقات المثبتة", "navigation_bar.pins": "التبويقات المثبتة",
"navigation_bar.preferences": "التفضيلات", "navigation_bar.preferences": "التفضيلات",
"navigation_bar.profile_directory": "دليل المستخدِمين",
"navigation_bar.public_timeline": "الخيط العام الموحد", "navigation_bar.public_timeline": "الخيط العام الموحد",
"navigation_bar.security": "الأمان", "navigation_bar.security": "الأمان",
"notification.favourite": "أُعجِب {name} بمنشورك", "notification.favourite": "أُعجِب {name} بمنشورك",
@ -249,19 +254,19 @@
"notification.mention": "{name} ذكرك", "notification.mention": "{name} ذكرك",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} قام بترقية تبويقك", "notification.reblog": "{name} قام بترقية تبويقك",
"notifications.clear": "إمسح الإخطارات", "notifications.clear": "امسح الإخطارات",
"notifications.clear_confirmation": "أمتأكد من أنك تود مسح جل الإخطارات الخاصة بك و المتلقاة إلى حد الآن ؟", "notifications.clear_confirmation": "أمتأكد من أنك تود مسح جل الإخطارات الخاصة بك و المتلقاة إلى حد الآن ؟",
"notifications.column_settings.alert": "إشعارات سطح المكتب", "notifications.column_settings.alert": "إشعارات سطح المكتب",
"notifications.column_settings.favourite": "المُفَضَّلة :", "notifications.column_settings.favourite": "المُفَضَّلة:",
"notifications.column_settings.filter_bar.advanced": "عرض كافة الفئات", "notifications.column_settings.filter_bar.advanced": "عرض كافة الفئات",
"notifications.column_settings.filter_bar.category": "شريط الفلترة السريعة", "notifications.column_settings.filter_bar.category": "شريط الفلترة السريعة",
"notifications.column_settings.filter_bar.show": "عرض", "notifications.column_settings.filter_bar.show": "عرض",
"notifications.column_settings.follow": "متابعُون جُدُد :", "notifications.column_settings.follow": "متابعُون جُدُد:",
"notifications.column_settings.mention": "الإشارات :", "notifications.column_settings.mention": "الإشارات:",
"notifications.column_settings.poll": "نتائج استطلاع الرأي:", "notifications.column_settings.poll": "نتائج استطلاع الرأي:",
"notifications.column_settings.push": "الإخطارات المدفوعة", "notifications.column_settings.push": "الإخطارات المدفوعة",
"notifications.column_settings.reblog": "الترقيّات:", "notifications.column_settings.reblog": "الترقيّات:",
"notifications.column_settings.show": "إعرِضها في عمود", "notifications.column_settings.show": "اعرِضها في عمود",
"notifications.column_settings.sound": "أصدر صوتا", "notifications.column_settings.sound": "أصدر صوتا",
"notifications.filter.all": "الكل", "notifications.filter.all": "الكل",
"notifications.filter.boosts": "الترقيات", "notifications.filter.boosts": "الترقيات",
@ -272,11 +277,11 @@
"notifications.group": "{count} إشعارات", "notifications.group": "{count} إشعارات",
"poll.closed": "انتهى", "poll.closed": "انتهى",
"poll.refresh": "تحديث", "poll.refresh": "تحديث",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}", "poll.total_votes": "{count, plural, one {# صوت} other {# أصوات}}",
"poll.vote": "صَوّت", "poll.vote": "صَوّت",
"poll_button.add_poll": "إضافة استطلاع للرأي", "poll_button.add_poll": "إضافة استطلاع للرأي",
"poll_button.remove_poll": "إزالة استطلاع الرأي", "poll_button.remove_poll": "إزالة استطلاع الرأي",
"privacy.change": "إضبط خصوصية المنشور", "privacy.change": "اضبط خصوصية المنشور",
"privacy.direct.long": "أنشر إلى المستخدمين المشار إليهم فقط", "privacy.direct.long": "أنشر إلى المستخدمين المشار إليهم فقط",
"privacy.direct.short": "مباشر", "privacy.direct.short": "مباشر",
"privacy.private.long": "أنشر لمتابعيك فقط", "privacy.private.long": "أنشر لمتابعيك فقط",
@ -285,20 +290,20 @@
"privacy.public.short": "للعامة", "privacy.public.short": "للعامة",
"privacy.unlisted.long": "لا تقم بإدراجه على الخيوط العامة", "privacy.unlisted.long": "لا تقم بإدراجه على الخيوط العامة",
"privacy.unlisted.short": "غير مدرج", "privacy.unlisted.short": "غير مدرج",
"regeneration_indicator.label": "جارٍ التحميل …", "regeneration_indicator.label": "جارٍ التحميل…",
"regeneration_indicator.sublabel": "جارٍ تجهيز تغذية صفحتك الرئيسية !", "regeneration_indicator.sublabel": "جارٍ تجهيز تغذية صفحتك الرئيسية!",
"relative_time.days": "{number}d", "relative_time.days": "{number}ي",
"relative_time.hours": "{number}h", "relative_time.hours": "{number}سا",
"relative_time.just_now": "الآن", "relative_time.just_now": "الآن",
"relative_time.minutes": "{number}m", "relative_time.minutes": "{number}د",
"relative_time.seconds": "{number}s", "relative_time.seconds": "{number}ثا",
"reply_indicator.cancel": "إلغاء", "reply_indicator.cancel": "إلغاء",
"report.forward": "التحويل إلى {target}", "report.forward": "التحويل إلى {target}",
"report.forward_hint": "هذا الحساب ينتمي إلى خادوم آخَر. هل تودّ إرسال نسخة مجهولة مِن التقرير إلى هنالك أيضًا ؟", "report.forward_hint": "هذا الحساب ينتمي إلى خادوم آخَر. هل تودّ إرسال نسخة مجهولة مِن التقرير إلى هنالك أيضًا؟",
"report.hint": "سوف يتم إرسال التقرير إلى المُشرِفين على خادومكم. بإمكانكم الإدلاء بشرح عن سبب الإبلاغ عن الحساب أسفله:", "report.hint": "سوف يتم إرسال التقرير إلى المُشرِفين على خادومكم. بإمكانكم الإدلاء بشرح عن سبب الإبلاغ عن الحساب أسفله:",
"report.placeholder": "تعليقات إضافية", "report.placeholder": "تعليقات إضافية",
"report.submit": "إرسال", "report.submit": "إرسال",
"report.target": "إبلاغ", "report.target": "ابلغ عن {target}",
"search.placeholder": "ابحث", "search.placeholder": "ابحث",
"search_popout.search_format": "نمط البحث المتقدم", "search_popout.search_format": "نمط البحث المتقدم",
"search_popout.tips.full_text": "النص البسيط يقوم بعرض المنشورات التي كتبتها أو قمت بإرسالها أو ترقيتها أو تمت الإشارة إليك فيها من طرف آخرين ، بالإضافة إلى مطابقة أسماء المستخدمين وأسماء العرض وعلامات التصنيف.", "search_popout.tips.full_text": "النص البسيط يقوم بعرض المنشورات التي كتبتها أو قمت بإرسالها أو ترقيتها أو تمت الإشارة إليك فيها من طرف آخرين ، بالإضافة إلى مطابقة أسماء المستخدمين وأسماء العرض وعلامات التصنيف.",
@ -312,11 +317,11 @@
"search_results.total": "{count, number} {count, plural, one {result} و {results}}", "search_results.total": "{count, number} {count, plural, one {result} و {results}}",
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}", "status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
"status.admin_status": "افتح هذا المنشور على واجهة الإشراف", "status.admin_status": "افتح هذا المنشور على واجهة الإشراف",
"status.block": "Block @{name}", "status.block": "احجب @{name}",
"status.cancel_reblog_private": "إلغاء الترقية", "status.cancel_reblog_private": "إلغاء الترقية",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.copy": "نسخ رابط المنشور", "status.copy": "نسخ رابط المنشور",
"status.delete": "إحذف", "status.delete": "احذف",
"status.detailed_status": "تفاصيل المحادثة", "status.detailed_status": "تفاصيل المحادثة",
"status.direct": "رسالة خاصة إلى @{name}", "status.direct": "رسالة خاصة إلى @{name}",
"status.embed": "إدماج", "status.embed": "إدماج",
@ -339,33 +344,32 @@
"status.redraft": "إزالة و إعادة الصياغة", "status.redraft": "إزالة و إعادة الصياغة",
"status.reply": "ردّ", "status.reply": "ردّ",
"status.replyAll": "رُد على الخيط", "status.replyAll": "رُد على الخيط",
"status.report": "إبلِغ عن @{name}", "status.report": "ابلِغ عن @{name}",
"status.sensitive_toggle": "اضغط للعرض",
"status.sensitive_warning": "محتوى حساس", "status.sensitive_warning": "محتوى حساس",
"status.share": "مشاركة", "status.share": "مشاركة",
"status.show_less": "إعرض أقلّ", "status.show_less": "اعرض أقلّ",
"status.show_less_all": "طي الكل", "status.show_less_all": "طي الكل",
"status.show_more": "أظهر المزيد", "status.show_more": "أظهر المزيد",
"status.show_more_all": "توسيع الكل", "status.show_more_all": "توسيع الكل",
"status.show_thread": "الكشف عن المحادثة", "status.show_thread": "الكشف عن المحادثة",
"status.unmute_conversation": "فك الكتم عن المحادثة", "status.unmute_conversation": "فك الكتم عن المحادثة",
"status.unpin": "فك التدبيس من الملف الشخصي", "status.unpin": "فك التدبيس من الملف الشخصي",
"suggestions.dismiss": "إلغاء الإقتراح", "suggestions.dismiss": "إلغاء الاقتراح",
"suggestions.header": "يمكن أن يهمك…", "suggestions.header": "يمكن أن يهمك…",
"tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.federated_timeline": "الموحَّد",
"tabs_bar.home": "الرئيسية", "tabs_bar.home": "الرئيسية",
"tabs_bar.local_timeline": "المحلي", "tabs_bar.local_timeline": "الخيط العام المحلي",
"tabs_bar.notifications": "الإخطارات", "tabs_bar.notifications": "الإخطارات",
"tabs_bar.search": "البحث", "tabs_bar.search": "البحث",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.days": "{number, plural, one {# يوم} other {# أيام}} متبقية",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining", "time_remaining.moments": "لحظات متبقية",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} آخرون {people}} يتحدثون", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} آخرون {people}} يتحدثون",
"ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.", "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
"upload_area.title": "إسحب ثم أفلت للرفع", "upload_area.title": "اسحب ثم أفلت للرفع",
"upload_button.label": "إضافة وسائط (JPEG، PNG، GIF، WebM، MP4، MOV)", "upload_button.label": "إضافة وسائط ({formats})",
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.", "upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",
"upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.", "upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.",
"upload_form.description": "وصف للمعاقين بصريا", "upload_form.description": "وصف للمعاقين بصريا",

View file

@ -77,6 +77,7 @@
"compose_form.poll.remove_option": "Remove this choice", "compose_form.poll.remove_option": "Remove this choice",
"compose_form.publish": "Toot", "compose_form.publish": "Toot",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "Mark media as sensitive",
"compose_form.sensitive.marked": "Media is marked as sensitive", "compose_form.sensitive.marked": "Media is marked as sensitive",
"compose_form.sensitive.unmarked": "Media is not marked as sensitive", "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "El testu nun va anubrise darrera d'una alvertencia", "compose_form.spoiler.marked": "El testu nun va anubrise darrera d'una alvertencia",
@ -165,7 +166,7 @@
"introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!", "introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!",
"introduction.federation.local.headline": "Local", "introduction.federation.local.headline": "Local",
"introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.", "introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.",
"introduction.interactions.action": "Finish tutorial!", "introduction.interactions.action": "Finish toot-orial!",
"introduction.interactions.favourite.headline": "Favourite", "introduction.interactions.favourite.headline": "Favourite",
"introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.", "introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.",
"introduction.interactions.reblog.headline": "Boost", "introduction.interactions.reblog.headline": "Boost",
@ -203,12 +204,14 @@
"keyboard_shortcuts.search": "to focus search", "keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.start": "p'abrir la columna «entamar»", "keyboard_shortcuts.start": "p'abrir la columna «entamar»",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
"keyboard_shortcuts.toot": "p'apenzar un toot nuevu", "keyboard_shortcuts.toot": "p'apenzar un toot nuevu",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "pa xubir na llista", "keyboard_shortcuts.up": "pa xubir na llista",
"lightbox.close": "Close", "lightbox.close": "Close",
"lightbox.next": "Siguiente", "lightbox.next": "Siguiente",
"lightbox.previous": "Previous", "lightbox.previous": "Previous",
"lightbox.view_context": "View context",
"lists.account.add": "Amestar a la llista", "lists.account.add": "Amestar a la llista",
"lists.account.remove": "Desaniciar de la llista", "lists.account.remove": "Desaniciar de la llista",
"lists.delete": "Desaniciar la llista", "lists.delete": "Desaniciar la llista",
@ -234,6 +237,7 @@
"navigation_bar.favourites": "Favoritos", "navigation_bar.favourites": "Favoritos",
"navigation_bar.filters": "Pallabres silenciaes", "navigation_bar.filters": "Pallabres silenciaes",
"navigation_bar.follow_requests": "Solicitúes de siguimientu", "navigation_bar.follow_requests": "Solicitúes de siguimientu",
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.info": "Tocante a esta instancia", "navigation_bar.info": "Tocante a esta instancia",
"navigation_bar.keyboard_shortcuts": "Atayos", "navigation_bar.keyboard_shortcuts": "Atayos",
"navigation_bar.lists": "Llistes", "navigation_bar.lists": "Llistes",
@ -242,6 +246,7 @@
"navigation_bar.personal": "Personal", "navigation_bar.personal": "Personal",
"navigation_bar.pins": "Toots fixaos", "navigation_bar.pins": "Toots fixaos",
"navigation_bar.preferences": "Preferencies", "navigation_bar.preferences": "Preferencies",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Llinia temporal federada", "navigation_bar.public_timeline": "Llinia temporal federada",
"navigation_bar.security": "Seguranza", "navigation_bar.security": "Seguranza",
"notification.favourite": "{name} favourited your status", "notification.favourite": "{name} favourited your status",
@ -340,7 +345,6 @@
"status.reply": "Responder", "status.reply": "Responder",
"status.replyAll": "Reply to thread", "status.replyAll": "Reply to thread",
"status.report": "Report @{name}", "status.report": "Report @{name}",
"status.sensitive_toggle": "Fai clic pa velu",
"status.sensitive_warning": "Conteníu sensible", "status.sensitive_warning": "Conteníu sensible",
"status.share": "Share", "status.share": "Share",
"status.show_less": "Amosar menos", "status.show_less": "Amosar menos",

View file

@ -77,6 +77,7 @@
"compose_form.poll.remove_option": "Remove this choice", "compose_form.poll.remove_option": "Remove this choice",
"compose_form.publish": "Раздумай", "compose_form.publish": "Раздумай",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "Mark media as sensitive",
"compose_form.sensitive.marked": "Media is marked as sensitive", "compose_form.sensitive.marked": "Media is marked as sensitive",
"compose_form.sensitive.unmarked": "Media is not marked as sensitive", "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning", "compose_form.spoiler.marked": "Text is hidden behind warning",
@ -165,7 +166,7 @@
"introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!", "introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!",
"introduction.federation.local.headline": "Local", "introduction.federation.local.headline": "Local",
"introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.", "introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.",
"introduction.interactions.action": "Finish tutorial!", "introduction.interactions.action": "Finish toot-orial!",
"introduction.interactions.favourite.headline": "Favourite", "introduction.interactions.favourite.headline": "Favourite",
"introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.", "introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.",
"introduction.interactions.reblog.headline": "Boost", "introduction.interactions.reblog.headline": "Boost",
@ -203,12 +204,14 @@
"keyboard_shortcuts.search": "to focus search", "keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
"keyboard_shortcuts.toot": "to start a brand new toot", "keyboard_shortcuts.toot": "to start a brand new toot",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "to move up in the list", "keyboard_shortcuts.up": "to move up in the list",
"lightbox.close": "Затвори", "lightbox.close": "Затвори",
"lightbox.next": "Next", "lightbox.next": "Next",
"lightbox.previous": "Previous", "lightbox.previous": "Previous",
"lightbox.view_context": "View context",
"lists.account.add": "Add to list", "lists.account.add": "Add to list",
"lists.account.remove": "Remove from list", "lists.account.remove": "Remove from list",
"lists.delete": "Delete list", "lists.delete": "Delete list",
@ -234,6 +237,7 @@
"navigation_bar.favourites": "Favourites", "navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words", "navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests", "navigation_bar.follow_requests": "Follow requests",
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.info": "Extended information", "navigation_bar.info": "Extended information",
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts", "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.lists": "Lists", "navigation_bar.lists": "Lists",
@ -242,6 +246,7 @@
"navigation_bar.personal": "Personal", "navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned toots", "navigation_bar.pins": "Pinned toots",
"navigation_bar.preferences": "Предпочитания", "navigation_bar.preferences": "Предпочитания",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Публичен канал", "navigation_bar.public_timeline": "Публичен канал",
"navigation_bar.security": "Security", "navigation_bar.security": "Security",
"notification.favourite": "{name} хареса твоята публикация", "notification.favourite": "{name} хареса твоята публикация",
@ -340,7 +345,6 @@
"status.reply": "Отговор", "status.reply": "Отговор",
"status.replyAll": "Reply to thread", "status.replyAll": "Reply to thread",
"status.report": "Report @{name}", "status.report": "Report @{name}",
"status.sensitive_toggle": "Покажи",
"status.sensitive_warning": "Деликатно съдържание", "status.sensitive_warning": "Деликатно съдържание",
"status.share": "Share", "status.share": "Share",
"status.show_less": "Show less", "status.show_less": "Show less",

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