Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Erin Shepherd 2022-12-19 01:58:20 +00:00
commit a80e6a84d8
606 changed files with 35521 additions and 12742 deletions

View file

@ -1,8 +1,8 @@
version: 2.1
orbs:
ruby: circleci/ruby@1.4.1
node: circleci/node@5.0.1
ruby: circleci/ruby@2.0.0
node: circleci/node@5.0.3
executors:
default:
@ -19,11 +19,11 @@ executors:
DB_USER: root
DISABLE_SIMPLECOV: true
RAILS_ENV: test
- image: cimg/postgres:14.0
- image: cimg/postgres:14.5
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: cimg/redis:6.2
- image: cimg/redis:7.0
commands:
install-system-dependencies:
@ -45,7 +45,7 @@ commands:
bundle config without 'development production'
name: Set bundler settings
- ruby/install-deps:
bundler-version: '2.3.8'
bundler-version: '2.3.26'
key: ruby<< parameters.ruby-version >>-gems-v1
wait-db:
steps:
@ -68,7 +68,9 @@ jobs:
cache-version: v1
pkg-manager: yarn
- run:
command: ./bin/rails assets:precompile
command: |
export NODE_OPTIONS=--openssl-legacy-provider
./bin/rails assets:precompile
name: Precompile assets
- persist_to_workspace:
paths:
@ -219,5 +221,5 @@ workflows:
pkg-manager: yarn
requires:
- build
version: lts
version: '16.18'
yarn-run: test:jest

View file

@ -9,7 +9,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev"
# [Choice] Node.js version: lts/*, 16, 14, 12, 10
# [Choice] Node.js version: lts/*, 18, 16, 14
ARG NODE_VERSION="lts/*"
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"

View file

@ -2,7 +2,7 @@
"name": "Mastodon",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/mastodon",
"workspaceFolder": "/mastodon",
// Set *default* container specific settings.json values on container create.
"settings": {},
@ -20,7 +20,7 @@
"forwardPorts": [3000, 4000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bundle install --path vendor/bundle && yarn install && git checkout -- Gemfile.lock && ./bin/rails db:setup",
"postCreateCommand": ".devcontainer/post-create.sh",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"

View file

@ -11,9 +11,9 @@ services:
# Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: '3.0-bullseye'
# Optional Node.js version to install
NODE_VERSION: '14'
NODE_VERSION: '16'
volumes:
- ..:/workspaces/mastodon:cached
- ..:/mastodon:cached
environment:
RAILS_ENV: development
NODE_ENV: development

21
.devcontainer/post-create.sh Executable file
View file

@ -0,0 +1,21 @@
#!/bin/bash
set -e # Fail the whole script on first error
# Fetch Ruby gem dependencies
bundle install --path vendor/bundle --with='development test'
# Fetch Javascript dependencies
yarn install
# Make Gemfile.lock pristine again
git checkout -- Gemfile.lock
# [re]create, migrate, and seed the test database
RAILS_ENV=test ./bin/rails db:setup
# Precompile assets for development
RAILS_ENV=development ./bin/rails assets:precompile
# Precompile assets for test
RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile

View file

@ -103,7 +103,7 @@ VAPID_PUBLIC_KEY=
# Sending mail
# ------------
SMTP_SERVER=smtp.mailgun.org
SMTP_SERVER=
SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=

View file

@ -1,6 +1,6 @@
name: Bug Report
description: If something isn't working as expected
labels: bug
labels: [bug]
body:
- type: markdown
attributes:
@ -50,7 +50,7 @@ body:
Google Chrome 106.0.5249.119
Firefox 105.0.3
etc...
validations:
required: true

View file

@ -1,6 +1,6 @@
name: Feature Request
description: I have a suggestion
labels: suggestion
labels: [suggestion]
body:
- type: markdown
attributes:

View file

@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: GitHub Discussions
url: https://github.com/mastodon/mastodon/discussions
about: Please ask and answer questions here.
about: Please ask and answer questions here.

View file

@ -4,8 +4,6 @@ on:
push:
branches:
- 'main'
tags:
- '*'
pull_request:
paths:
- .github/workflows/build-image.yml
@ -19,6 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hadolint/hadolint-action@v3.0.0
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
@ -31,18 +30,16 @@ jobs:
id: meta
with:
images: ghcr.io/${{ github.repository_owner }}/mastodon
flavor: |
latest=auto
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=edge,branch=main
type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}
type=ref,event=pr
type=sha,prefix=,format=long
- uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
builder: ${{ steps.buildx.outputs.name }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:edge
cache-to: type=inline
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -25,7 +25,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
ruby-version: .ruby-version
bundler-cache: true
- name: Check locale file normalization
run: bundle exec i18n-tasks check-normalized

63
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,63 @@
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '22 6 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'ruby' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View file

@ -53,16 +53,18 @@ jobs:
- name: Set-up Node.js
uses: actions/setup-node@v3
with:
node-version: 16.x
node-version-file: .nvmrc
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check prettier formatting
run: yarn format-check
- name: Set-up RuboCop Problem Mathcher
uses: r7kamura/rubocop-problem-matchers-action@v1
- name: Set-up Stylelint Problem Matcher
uses: xt0rted/stylelint-problem-matcher@v1
# https://github.com/xt0rted/stylelint-problem-matcher/issues/360
- run: echo "::add-matcher::.github/stylelint-matcher.json"
- run: echo "::add-matcher::.github/stylelint-matcher.json"
################################
# Run Linter against code base #

17
.github/workflows/rebase-needed.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: PR Needs Rebase
on:
push:
pull_request_target:
types: [synchronize]
jobs:
label-rebase-needed:
runs-on: ubuntu-latest
steps:
- name: Check for merge conflicts
uses: eps1lon/actions-label-merge-conflict@releases/2.x
with:
dirtyLabel: 'rebase needed :construction:'
repoToken: '${{ secrets.GITHUB_TOKEN }}'
commentOnDirty: This pull request has merge conflicts that must be resolved before it can be merged.

View file

@ -1,138 +0,0 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
#
name: Test chart
on:
pull_request:
paths:
- "chart/**"
- "!**.md"
- ".github/workflows/test-chart.yml"
push:
paths:
- "chart/**"
- "!**.md"
- ".github/workflows/test-chart.yml"
branches-ignore:
- "dependabot/**"
workflow_dispatch:
permissions:
contents: read
defaults:
run:
working-directory: chart
jobs:
lint-templates:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install dependencies (yamllint)
run: pip install yamllint
- run: helm dependency update
- name: helm lint
run: |
helm lint . \
--values dev-values.yaml
- name: helm template
run: |
helm template . \
--values dev-values.yaml \
--output-dir rendered-templates
- name: yamllint (only on templates we manage)
run: |
rm -rf rendered-templates/mastodon/charts
yamllint rendered-templates \
--config-data "{rules: {indentation: {spaces: 2}, line-length: disable}}"
# This job helps us validate that rendered templates are valid k8s resources
# against a k8s api-server, via "helm template --validate", but also that a
# basic configuration can be used to successfully startup mastodon.
#
test-install:
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# k3s-channel reference: https://update.k3s.io/v1-release/channels
- k3s-channel: latest
- k3s-channel: stable
# This represents the oldest configuration we test against.
#
# The k8s version chosen is based on the oldest still supported k8s
# version among two managed k8s services, GKE, EKS.
# - GKE: https://endoflife.date/google-kubernetes-engine
# - EKS: https://endoflife.date/amazon-eks
#
# The helm client's version can influence what helper functions is
# available for use in the templates, currently we need v3.6.0 or
# higher.
#
- k3s-channel: v1.21
helm-version: v3.6.0
steps:
- uses: actions/checkout@v3
# This action starts a k8s cluster with NetworkPolicy enforcement and
# installs both kubectl and helm.
#
# ref: https://github.com/jupyterhub/action-k3s-helm#readme
#
- uses: jupyterhub/action-k3s-helm@v3
with:
k3s-channel: ${{ matrix.k3s-channel }}
helm-version: ${{ matrix.helm-version }}
metrics-enabled: false
traefik-enabled: false
docker-enabled: false
- run: helm dependency update
# Validate rendered helm templates against the k8s api-server
- name: helm template --validate
run: |
helm template --validate mastodon . \
--values dev-values.yaml
- name: helm install
run: |
helm install mastodon . \
--values dev-values.yaml \
--timeout 10m
# This actions provides a report about the state of the k8s cluster,
# providing logs etc on anything that has failed and workloads marked as
# important.
#
# ref: https://github.com/jupyterhub/action-k8s-namespace-report#readme
#
- name: Kubernetes namespace report
uses: jupyterhub/action-k8s-namespace-report@v1
if: always()
with:
important-workloads: >-
deploy/mastodon-sidekiq
deploy/mastodon-streaming
deploy/mastodon-web
job/mastodon-assets-precompile
job/mastodon-chewy-upgrade
job/mastodon-create-admin
job/mastodon-db-migrate

6
.gitignore vendored
View file

@ -44,12 +44,6 @@
/redis
/elasticsearch
# ignore Helm charts
/chart/*.tgz
# ignore Helm dependency charts
/chart/charts/*.tgz
# Ignore Apple files
.DS_Store

2
.nvmrc
View file

@ -1 +1 @@
14
16

View file

@ -44,9 +44,6 @@
/redis
/elasticsearch
# ignore Helm dependency charts
/chart/charts/*.tgz
# Ignore Apple files
.DS_Store
@ -67,9 +64,6 @@ yarn-debug.log
# Ignore Docker option files
docker-compose.override.yml
# Ignore Helm files
/chart
# Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json

View file

@ -1,12 +1,18 @@
require:
- rubocop-rails
- rubocop-rspec
- rubocop-performance
AllCops:
TargetRubyVersion: 2.5
NewCops: disable
TargetRubyVersion: 2.7
DisplayCopNames: true
DisplayStyleGuide: true
ExtraDetails: true
UseCache: true
CacheRootDirectory: tmp
NewCops: enable
Exclude:
- 'spec/**/*'
- 'db/**/*'
- db/schema.rb
- 'app/views/**/*'
- 'config/**/*'
- 'bin/*'
@ -67,15 +73,57 @@ Lint/UselessAccessModifier:
- class_methods
Metrics/AbcSize:
Max: 115
Max: 34 # RuboCop default 17
Exclude:
- 'lib/mastodon/*_cli.rb'
- 'lib/**/*cli*.rb'
- db/*migrate/**/*
- lib/paperclip/color_extractor.rb
- app/workers/scheduler/follow_recommendations_scheduler.rb
- app/services/activitypub/fetch*_service.rb
- lib/paperclip/**/*
CountRepeatedAttributes: false
AllowedMethods:
- update_media_attachments!
- account_link_to
- attempt_oembed
- build_crutches
- calculate_scores
- cc
- dump_actor!
- filter_from_home?
- hydrate
- import_bookmarks!
- import_relationships!
- initialize
- link_to_mention
- log_target
- matches_time_window?
- parse_metadata
- perform_statuses_search!
- privatize_media_attachments!
- process_update
- publish_media_attachments!
- remotable_attachment
- render_initial_state
- render_with_cache
- searchable_by
- self.cached_filters_for
- set_fetchable_attributes!
- signed_request_actor
- statuses_to_delete
- update_poll!
Metrics/BlockLength:
Max: 55
Exclude:
- 'lib/tasks/**/*'
- 'lib/mastodon/*_cli.rb'
CountComments: false
CountAsOne: [array, heredoc]
AllowedMethods:
- task
- namespace
- class_methods
- included
Metrics/BlockNesting:
Max: 3
@ -85,34 +133,144 @@ Metrics/BlockNesting:
Metrics/ClassLength:
CountComments: false
Max: 500
CountAsOne: [array, heredoc]
Exclude:
- 'lib/mastodon/*_cli.rb'
Metrics/CyclomaticComplexity:
Max: 25
Max: 12
Exclude:
- 'lib/mastodon/*_cli.rb'
- lib/mastodon/*cli*.rb
- db/*migrate/**/*
AllowedMethods:
- attempt_oembed
- blocked?
- build_crutches
- calculate_scores
- cc
- discover_endpoint!
- filter_from_home?
- hydrate
- klass
- link_to_mention
- log_target
- matches_time_window?
- patch_for_forwarding!
- preprocess_attributes!
- process_update
- remotable_attachment
- scan_text!
- self.cached_filters_for
- set_fetchable_attributes!
- setup_redis_env_url
- update_media_attachments!
Layout/LineLength:
Max: 140 # RuboCop default 120
AllowHeredoc: true
AllowURI: true
Enabled: false
IgnoreCopDirectives: true
AllowedPatterns:
# Allow comments to be long lines
- !ruby/regexp / \# .*$/
- !ruby/regexp /^\# .*$/
Exclude:
- lib/**/*cli*.rb
- db/*migrate/**/*
- db/seeds/**/*
Metrics/MethodLength:
CountComments: false
Max: 65
CountAsOne: [array, heredoc]
Max: 25 # RuboCop default 10
Exclude:
- 'lib/mastodon/*_cli.rb'
AllowedMethods:
- account_link_to
- attempt_oembed
- body_with_limit
- build_crutches
- cached_filters_for
- calculate_scores
- check_webfinger!
- clean_feeds!
- collection_items
- collection_presenter
- copy_account_notes!
- deduplicate_accounts!
- deduplicate_conversations!
- deduplicate_local_accounts!
- deduplicate_statuses!
- deduplicate_tags!
- deduplicate_users!
- discover_endpoint!
- extract_extra_uris_with_indices
- extract_hashtags_with_indices
- extract_mentions_or_lists_with_indices
- filter_from_home?
- from_elasticsearch
- handle_explicit_update!
- handle_mark_as_sensitive!
- hsl_to_rgb
- import_bookmarks!
- import_domain_blocks!
- import_relationships!
- ldap_options
- matches_time_window?
- outbox_presenter
- pam_get_user
- parallelize_with_progress
- parse_and_transform
- patch_for_forwarding!
- populate_home
- post_process_style
- preload_cache_collection_target_statuses
- privatize_media_attachments!
- provides_callback_for
- publish_media_attachments!
- relevant_account_timestamp
- remotable_attachment
- rgb_to_hsl
- rss_status_content_format
- set_fetchable_attributes!
- setup_redis_env_url
- signed_request_actor
- to_preview_card_attributes
- upgrade_storage_filesystem
- upgrade_storage_s3
- user_settings_params
- hydrate
- cc
- self_destruct
Metrics/ModuleLength:
CountComments: false
Max: 200
CountAsOne: [array, heredoc]
Metrics/ParameterLists:
Max: 5
CountKeywordArgs: true
Max: 5 # RuboCop default 5
CountKeywordArgs: true # RuboCop default true
MaxOptionalParameters: 3 # RuboCop default 3
Exclude:
- app/models/concerns/account_interactions.rb
- app/services/activitypub/fetch_remote_account_service.rb
- app/services/activitypub/fetch_remote_actor_service.rb
Metrics/PerceivedComplexity:
Max: 25
Max: 16 # RuboCop default 8
AllowedMethods:
- attempt_oembed
- build_crutches
- calculate_scores
- deduplicate_users!
- discover_endpoint!
- filter_from_home?
- hydrate
- patch_for_forwarding!
- process_update
- remove_orphans
- update_media_attachments!
Naming/MemoizedInstanceVariableName:
Enabled: false
@ -243,6 +401,10 @@ Style/HashTransformKeys:
Style/HashTransformValues:
Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: ruby19_no_mixed_keys
Style/IfUnlessModifier:
Enabled: false
@ -263,9 +425,6 @@ Style/PercentLiteralDelimiters:
Style/PerlBackrefs:
AutoCorrect: false
Style/RedundantAssignment:
Enabled: false
Style/RedundantFetchBlock:
Enabled: true
@ -288,7 +447,7 @@ Style/RegexpLiteral:
Enabled: false
Style/RescueStandardError:
Enabled: false
Enabled: true
Style/SignalException:
Enabled: false
@ -307,3 +466,14 @@ Style/TrailingCommaInHashLiteral:
Style/UnpackFirst:
Enabled: false
RSpec/ScatteredSetup:
Enabled: false
RSpec/ImplicitExpect:
Enabled: false
RSpec/NamedSubject:
Enabled: false
RSpec/DescribeClass:
Enabled: false
RSpec/LetSetup:
Enabled: false

22
Aptfile
View file

@ -1,26 +1,4 @@
ffmpeg
libicu[0-9][0-9]
libicu-dev
libidn12
libidn-dev
libpq-dev
libxdamage1
libxfixes3
zlib1g-dev
libcairo2
libcroco3
libdatrie1
libgdk-pixbuf2.0-0
libgraphite2-3
libharfbuzz0b
libpango-1.0-0
libpangocairo-1.0-0
libpangoft2-1.0-0
libpixman-1-0
librsvg2-2
libthai-data
libthai0
libvpx[5-9]
libxcb-render0
libxcb-shm0
libxrender1

View file

@ -3,6 +3,13 @@ Changelog
All notable changes to this project will be documented in this file.
## [4.0.2] - 2022-11-15
### Fixed
- Fix wrong color on mentions hidden behind content warning in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20724))
- Fix filters from other users being used in the streaming service ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20719))
- Fix `unsafe-eval` being used when `wasm-unsafe-eval` is enough in Content Security Policy ([Gargron](https://github.com/mastodon/mastodon/pull/20729), [prplecake](https://github.com/mastodon/mastodon/pull/20606))
## [4.0.1] - 2022-11-14
### Fixed

View file

@ -1,121 +1,99 @@
FROM ubuntu:20.04 as build-dep
# syntax=docker/dockerfile:1.4
# This needs to be bullseye-slim because the Ruby image is built on bullseye-slim
ARG NODE_VERSION="16.18.1-bullseye-slim"
# Use bash for the shell
SHELL ["/bin/bash", "-c"]
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.0.4-slim as ruby
FROM node:${NODE_VERSION} as build
# Install Node v16 (LTS)
ENV NODE_VER="16.17.1"
RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \
amd64) ARCH='x64';; \
ppc64el) ARCH='ppc64le';; \
s390x) ARCH='s390x';; \
arm64) ARCH='arm64';; \
armhf) ARCH='armv7l';; \
i386) ARCH='x86';; \
*) echo "unsupported architecture"; exit 1 ;; \
esac && \
echo "Etc/UTC" > /etc/localtime && \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates wget python3 apt-utils && \
cd ~ && \
wget -q https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \
tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \
rm node-v$NODE_VER-linux-$ARCH.tar.gz && \
mv node-v$NODE_VER-linux-$ARCH /opt/node
COPY --link --from=ruby /opt/ruby /opt/ruby
# Install Ruby 3.0
ENV RUBY_VER="3.0.4"
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \
cd ~ && \
wget https://cache.ruby-lang.org/pub/ruby/${RUBY_VER%.*}/ruby-$RUBY_VER.tar.gz && \
tar xf ruby-$RUBY_VER.tar.gz && \
cd ruby-$RUBY_VER && \
./configure --prefix=/opt/ruby \
--with-jemalloc \
--with-shared \
--disable-install-doc && \
make -j"$(nproc)" > /dev/null && \
make install && \
rm -rf ../ruby-$RUBY_VER.tar.gz ../ruby-$RUBY_VER
ENV DEBIAN_FRONTEND="noninteractive" \
PATH="${PATH}:/opt/ruby/bin"
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin"
RUN npm install -g npm@latest && \
npm install -g yarn && \
gem install bundler && \
apt-get update && \
apt-get install -y --no-install-recommends git libicu-dev libidn11-dev \
libpq-dev shared-mime-info
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /opt/mastodon
COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN cd /opt/mastodon && \
bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle config set silence_root_warning true && \
bundle install -j"$(nproc)" && \
yarn install --pure-lockfile
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \
ca-certificates \
git \
libicu-dev \
libidn11-dev \
libpq-dev \
libjemalloc-dev \
zlib1g-dev \
libgdbm-dev \
libgmp-dev \
libssl-dev \
libyaml-0-2 \
ca-certificates \
libreadline8 \
python3 \
shared-mime-info && \
bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle config set silence_root_warning true && \
bundle install -j"$(nproc)" && \
yarn install --pure-lockfile --network-timeout 600000
FROM ubuntu:20.04
FROM node:${NODE_VERSION}
# Copy over all the langs needed for runtime
COPY --from=build-dep /opt/node /opt/node
COPY --from=build-dep /opt/ruby /opt/ruby
ARG UID="991"
ARG GID="991"
# Add more PATHs to the PATH
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin"
COPY --link --from=ruby /opt/ruby /opt/ruby
# Create the mastodon user
ARG UID=991
ARG GID=991
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update && \
echo "Etc/UTC" > /etc/localtime && \
apt-get install -y --no-install-recommends whois wget && \
addgroup --gid $GID mastodon && \
useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \
echo "mastodon:$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256)" | chpasswd && \
rm -rf /var/lib/apt/lists/*
# Install mastodon runtime deps
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
RUN apt-get update && \
apt-get -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg libjemalloc2 \
libicu66 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline8 gcc tini apt-utils && \
ln -s /opt/mastodon /mastodon && \
gem install bundler && \
rm -rf /var/cache && \
rm -rf /var/lib/apt/lists/*
ENV DEBIAN_FRONTEND="noninteractive" \
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"
# Ignoreing these here since we don't want to pin any versions and the Debian image removes apt-get content after use
# hadolint ignore=DL3008,DL3009
RUN apt-get update && \
echo "Etc/UTC" > /etc/localtime && \
groupadd -g "${GID}" mastodon && \
useradd -l -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \
apt-get -y --no-install-recommends install whois \
wget \
procps \
libssl1.1 \
libpq5 \
imagemagick \
ffmpeg \
libjemalloc2 \
libicu67 \
libidn11 \
libyaml-0-2 \
file \
ca-certificates \
tzdata \
libreadline8 \
tini && \
ln -s /opt/mastodon /mastodon
# Note: no, cleaning here since Debian does this automatically
# See the file /etc/apt/apt.conf.d/docker-clean within the Docker image's filesystem
# Copy over mastodon source, and dependencies from building, and set permissions
COPY --chown=mastodon:mastodon . /opt/mastodon
COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon
COPY --chown=mastodon:mastodon --from=build /opt/mastodon /opt/mastodon
# Run mastodon services in prod mode
ENV RAILS_ENV="production"
ENV NODE_ENV="production"
# Tell rails to serve static files
ENV RAILS_SERVE_STATIC_FILES="true"
ENV BIND="0.0.0.0"
ENV RAILS_ENV="production" \
NODE_ENV="production" \
RAILS_SERVE_STATIC_FILES="true" \
BIND="0.0.0.0"
# Set the run user
USER mastodon
WORKDIR /opt/mastodon
# Precompile assets
RUN cd ~ && \
OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder rails assets:precompile && \
yarn cache clean
RUN OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder rails assets:precompile && \
yarn cache clean
# Set the work dir and the container entry point
WORKDIR /opt/mastodon
ENTRYPOINT ["/usr/bin/tini", "--"]
EXPOSE 3000 4000

30
Gemfile
View file

@ -1,9 +1,9 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '>= 2.6.0', '< 3.1.0'
ruby '>= 2.7.0', '< 3.1.0'
gem 'pkg-config', '~> 1.4'
gem 'pkg-config', '~> 1.5'
gem 'rexml', '~> 3.2'
gem 'puma', '~> 5.6'
@ -18,7 +18,7 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8'
gem 'dotenv-rails', '~> 2.8'
gem 'aws-sdk-s3', '~> 1.114', require: false
gem 'aws-sdk-s3', '~> 1.117', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.1'
@ -26,7 +26,7 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.13.0', require: false
gem 'bootsnap', '~> 1.15.0', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.2'
@ -55,7 +55,7 @@ gem 'redis-namespace', '~> 1.9'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 5.1'
gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.6.0'
gem 'httplog', '~> 1.6.2'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
@ -66,6 +66,7 @@ gem 'oj', '~> 3.13'
gem 'ox', '~> 2.14'
gem 'parslet'
gem 'posix-spawn'
gem 'public_suffix', '~> 5.0'
gem 'pundit', '~> 2.2'
gem 'premailer-rails'
gem 'rack-attack', '~> 6.6'
@ -86,7 +87,7 @@ gem 'sidekiq-bulk', '~> 0.2.0'
gem 'simple-navigation', '~> 4.4'
gem 'simple_form', '~> 5.1'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 3.0.0'
gem 'stoplight', '~> 3.0.1'
gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
@ -106,6 +107,10 @@ group :development, :test do
gem 'pry-byebug', '~> 3.10'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 5.1'
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop', require: false
end
group :production, :test do
@ -113,16 +118,17 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.37'
gem 'capybara', '~> 3.38'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.23'
gem 'faker', '~> 3.0'
gem 'json-schema', '~> 3.0'
gem 'microformats', '~> 4.4'
gem 'rack-test', '~> 2.0'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec_junit_formatter', '~> 0.6'
gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.18'
gem 'rspec_junit_formatter', '~> 0.6'
gem 'rack-test', '~> 2.0'
end
group :development do
@ -134,9 +140,7 @@ group :development do
gem 'letter_opener', '~> 1.8'
gem 'letter_opener_web', '~> 2.0'
gem 'memory_profiler'
gem 'rubocop', '~> 1.30', require: false
gem 'rubocop-rails', '~> 2.15', require: false
gem 'brakeman', '~> 5.3', require: false
gem 'brakeman', '~> 5.4', require: false
gem 'bundler-audit', '~> 0.9', require: false
gem 'capistrano', '~> 3.17'

View file

@ -90,20 +90,20 @@ GEM
attr_required (1.0.1)
awrence (1.2.1)
aws-eventstream (1.2.0)
aws-partitions (1.587.0)
aws-sdk-core (3.130.2)
aws-partitions (1.670.0)
aws-sdk-core (3.168.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.60.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.56.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-s3 (1.117.2)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.0)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.17)
better_errors (2.9.1)
@ -122,15 +122,15 @@ GEM
debug_inspector (>= 0.0.1)
blurhash (0.1.6)
ffi (~> 1.14)
bootsnap (1.13.0)
bootsnap (1.15.0)
msgpack (~> 1.2)
brakeman (5.3.1)
brakeman (5.4.0)
browser (4.2.0)
brpoplpush-redis_script (0.1.2)
brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, <= 5.0)
redis (>= 1.0, < 6)
builder (3.2.4)
bullet (7.0.3)
bullet (7.0.4)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.9.1)
@ -152,7 +152,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.37.1)
capybara (3.38.0)
addressable
matrix
mini_mime (>= 0.1.3)
@ -182,7 +182,7 @@ GEM
crack (0.4.5)
rexml
crass (1.0.6)
css_parser (1.7.1)
css_parser (1.12.0)
addressable
debug_inspector (1.0.0)
devise (4.8.1)
@ -206,7 +206,7 @@ GEM
docile (1.3.4)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.6.0)
doorkeeper (5.6.2)
railties (>= 5)
dotenv (2.8.1)
dotenv-rails (2.8.1)
@ -228,7 +228,7 @@ GEM
tzinfo
excon (0.76.0)
fabrication (2.30.0)
faker (2.23.0)
faker (3.0.0)
i18n (>= 1.8.11, < 2)
faraday (1.9.3)
faraday-em_http (~> 1.0)
@ -311,7 +311,7 @@ GEM
http-form_data (2.3.0)
http_accept_language (2.1.1)
httpclient (2.8.3)
httplog (1.6.0)
httplog (1.6.2)
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.12.0)
@ -327,9 +327,9 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
idn-ruby (0.1.4)
idn-ruby (0.1.5)
ipaddress (0.8.3)
jmespath (1.6.1)
jmespath (1.6.2)
json (2.6.2)
json-canonicalization (0.3.0)
json-jwt (1.13.0)
@ -343,9 +343,11 @@ GEM
multi_json (~> 1.15)
rack (~> 2.2)
rdf (~> 3.2, >= 3.2.9)
json-ld-preloaded (3.2.0)
json-ld-preloaded (3.2.2)
json-ld (~> 3.2)
rdf (~> 3.2)
json-schema (3.0.0)
addressable (>= 2.8)
jsonapi-renderer (0.2.2)
jwt (2.4.1)
kaminari (1.2.2)
@ -384,7 +386,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.19.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -395,7 +397,7 @@ GEM
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
matrix (0.4.2)
memory_profiler (1.0.0)
memory_profiler (1.0.1)
method_source (1.0.0)
microformats (4.4.1)
json (~> 2.2)
@ -406,15 +408,19 @@ GEM
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.16.3)
msgpack (1.5.4)
msgpack (1.6.0)
multi_json (1.15.0)
multipart-post (2.1.1)
net-ldap (0.17.1)
net-protocol (0.1.3)
timeout
net-scp (4.0.0.rc1)
net-ssh (>= 2.6.5, < 8.0.0)
net-smtp (0.3.3)
net-protocol
net-ssh (7.0.1)
nio4r (2.5.8)
nokogiri (1.13.8)
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nsa (0.2.8)
@ -422,7 +428,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.13.21)
oj (3.13.23)
omniauth (1.9.2)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
@ -457,17 +463,18 @@ GEM
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.4.3)
pg (1.4.5)
pghero (2.8.3)
activerecord (>= 5)
pkg-config (1.4.9)
pkg-config (1.5.1)
posix-spawn (0.3.15)
premailer (1.14.2)
premailer (1.18.0)
addressable
css_parser (>= 1.6.0)
css_parser (>= 1.12.0)
htmlentities (>= 4.0.0)
premailer-rails (1.11.1)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
pry (0.14.1)
@ -478,13 +485,13 @@ GEM
pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.0)
public_suffix (5.0.1)
puma (5.6.5)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
racc (1.6.1)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@ -522,8 +529,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1)
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
@ -539,13 +546,15 @@ GEM
rake (13.0.6)
rdf (3.2.9)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.5.0)
rdf-normalize (0.5.1)
rdf (~> 3.2)
redcarpet (3.5.1)
redis (4.5.1)
redis-namespace (1.9.0)
redis (>= 4)
regexp_parser (2.5.0)
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.6.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.0.1)
@ -580,21 +589,27 @@ GEM
rspec-support (3.11.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.30.1)
rubocop (1.39.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0)
rubocop-ast (>= 1.23.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.18.0)
rubocop-ast (1.23.0)
parser (>= 3.1.1.0)
rubocop-rails (2.15.0)
rubocop-performance (1.15.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.17.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.15.0)
rubocop (~> 1.33)
ruby-progressbar (1.11.0)
ruby-saml (1.13.0)
nokogiri (>= 1.10.5)
@ -611,8 +626,8 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
semantic_range (3.0.0)
sidekiq (6.5.7)
connection_pool (>= 2.2.5)
sidekiq (6.5.8)
connection_pool (>= 2.2.5, < 3)
rack (~> 2.0)
redis (>= 4.5.0, < 5)
sidekiq-bulk (0.2.0)
@ -622,10 +637,11 @@ GEM
rufus-scheduler (~> 3.2)
sidekiq (>= 4, < 7)
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.27)
sidekiq-unique-jobs (7.1.29)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 8.0)
redis (< 5.0)
sidekiq (>= 5.0, < 7.0)
thor (>= 0.20, < 3.0)
simple-navigation (4.4.0)
activesupport (>= 2.3.2)
@ -649,9 +665,10 @@ GEM
sshkit (1.21.2)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
stackprof (0.2.22)
stackprof (0.2.23)
statsd-ruby (1.5.0)
stoplight (3.0.0)
stoplight (3.0.1)
redlock (~> 1.0)
strong_migrations (0.7.9)
activerecord (>= 5)
swd (1.3.0)
@ -665,6 +682,7 @@ GEM
climate_control (>= 0.0.3, < 1.0)
thor (1.2.1)
tilt (2.0.11)
timeout (0.3.0)
tpm-key_attestation (0.11.0)
bindata (~> 2.4)
openssl (> 2.0, < 3.1)
@ -684,7 +702,7 @@ GEM
unf (~> 0.1.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2022.4)
tzinfo-data (1.2022.7)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
@ -727,7 +745,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.0)
zeitwerk (2.6.6)
PLATFORMS
ruby
@ -737,12 +755,12 @@ DEPENDENCIES
active_record_query_trace (~> 1.8)
addressable (~> 2.8)
annotate (~> 3.2)
aws-sdk-s3 (~> 1.114)
aws-sdk-s3 (~> 1.117)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
blurhash (~> 0.1)
bootsnap (~> 1.13.0)
brakeman (~> 5.3)
bootsnap (~> 1.15.0)
brakeman (~> 5.4)
browser
bullet (~> 7.0)
bundler-audit (~> 0.9)
@ -750,7 +768,7 @@ DEPENDENCIES
capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0)
capybara (~> 3.37)
capybara (~> 3.38)
charlock_holmes (~> 0.7.7)
chewy (~> 7.2)
climate_control (~> 0.2)
@ -766,7 +784,7 @@ DEPENDENCIES
dotenv-rails (~> 2.8)
ed25519 (~> 1.3)
fabrication (~> 2.30)
faker (~> 2.23)
faker (~> 3.0)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -779,11 +797,12 @@ DEPENDENCIES
htmlentities (~> 4.3)
http (~> 5.1)
http_accept_language (~> 2.1)
httplog (~> 1.6.0)
httplog (~> 1.6.2)
i18n-tasks (~> 1.0)
idn-ruby
json-ld
json-ld-preloaded (~> 3.2)
json-schema (~> 3.0)
kaminari (~> 1.2)
kt-paperclip (~> 7.1)
letter_opener (~> 1.8)
@ -807,12 +826,13 @@ DEPENDENCIES
parslet
pg (~> 1.4)
pghero (~> 2.8)
pkg-config (~> 1.4)
pkg-config (~> 1.5)
posix-spawn
premailer-rails
private_address_check (~> 0.5)
pry-byebug (~> 3.10)
pry-rails (~> 0.3)
public_suffix (~> 5.0)
puma (~> 5.6)
pundit (~> 2.2)
rack (~> 2.2.4)
@ -832,8 +852,10 @@ DEPENDENCIES
rspec-rails (~> 5.1)
rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.6)
rubocop (~> 1.30)
rubocop-rails (~> 2.15)
rubocop
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-progressbar (~> 1.11)
sanitize (~> 6.0)
scenic (~> 1.6)
@ -847,7 +869,7 @@ DEPENDENCIES
sprockets (~> 3.7.2)
sprockets-rails (~> 3.4)
stackprof
stoplight (~> 3.0.0)
stoplight (~> 3.0.1)
strong_migrations (~> 0.7)
thor (~> 1.2)
tty-prompt (~> 0.23)
@ -858,3 +880,9 @@ DEPENDENCIES
webpacker (~> 5.4)
webpush!
xorcist (~> 1.1)
RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.2.33

71
Vagrantfile vendored
View file

@ -3,16 +3,14 @@
ENV["PORT"] ||= "3000"
$provision = <<SCRIPT
cd /vagrant # This is where the host folder/repo is mounted
$provisionA = <<SCRIPT
# Add the yarn repo + yarn repo keys
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_14.x | sudo bash -
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
# Add firewall rule to redirect 80 to PORT and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]}
@ -33,32 +31,56 @@ sudo apt-get install \
redis-tools \
postgresql \
postgresql-contrib \
yarn \
libicu-dev \
libidn11-dev \
libreadline-dev \
libpam0g-dev \
libreadline6-dev \
autoconf \
bison \
build-essential \
ffmpeg \
file \
gcc \
libffi-dev \
libgdbm-dev \
libjemalloc-dev \
libncurses5-dev \
libprotobuf-dev \
libssl-dev \
libyaml-dev \
pkg-config \
protobuf-compiler \
zlib1g-dev \
-y
# Install rvm
read RUBY_VERSION < .ruby-version
sudo apt-add-repository -y ppa:rael-gc/rvm
sudo apt-get install rvm -y
curl -sSL https://rvm.io/mpapis.asc | gpg --import
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
sudo usermod -a -G rvm $USER
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm
SCRIPT
$provisionB = <<SCRIPT
source "/etc/profile.d/rvm.sh"
# Install Ruby
rvm reinstall ruby-$RUBY_VERSION --disable-binary
read RUBY_VERSION < /vagrant/.ruby-version
rvm install ruby-$RUBY_VERSION --disable-binary
# Configure database
sudo -u postgres createuser -U postgres vagrant -s
sudo -u postgres createdb -U postgres mastodon_development
# Install gems and node modules
cd /vagrant # This is where the host folder/repo is mounted
# Install gems
gem install bundler foreman
bundle install
# Install node modules
sudo corepack enable
yarn set version classic
yarn install
# Build Mastodon
@ -72,18 +94,11 @@ echo 'export $(cat "/vagrant/.env.vagrant" | xargs)' >> ~/.bash_profile
SCRIPT
$start = <<SCRIPT
echo 'To start server'
echo ' $ vagrant ssh -c "cd /vagrant && foreman start"'
SCRIPT
VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/bionic64"
config.vm.box = "ubuntu/focal64"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
@ -100,7 +115,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# Use "virtio" network interfaces for better performance.
vb.customize ["modifyvm", :id, "--nictype1", "virtio"]
vb.customize ["modifyvm", :id, "--nictype2", "virtio"]
end
# This uses the vagrant-hostsupdater plugin, and lets you
@ -118,7 +132,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
end
if config.vm.networks.any? { |type, options| type == :private_network }
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'vers=3', 'tcp', 'actimeo=1']
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
else
config.vm.synced_folder ".", "/vagrant"
end
@ -129,9 +143,12 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.network :forwarded_port, guest: 8080, host: 8080
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
config.vm.provision :shell, inline: $provision, privileged: false
config.vm.provision :shell, inline: $provisionA, privileged: false, reset: true
config.vm.provision :shell, inline: $provisionB, privileged: false
# Start up script, runs on every 'vagrant up'
config.vm.provision :shell, inline: $start, run: 'always', privileged: false
config.vm.post_up_message = <<MESSAGE
To start server
$ vagrant ssh -c "cd /vagrant && foreman start"
MESSAGE
end

View file

@ -17,6 +17,8 @@ class AccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in 0, public: true unless user_signed_in?
@rss_url = rss_url
end
format.rss do

View file

@ -55,12 +55,14 @@ module Admin
def approve
authorize @account.user, :approve?
@account.user.approve!
log_action :approve, @account.user
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
end
def reject
authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
log_action :reject, @account.user
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
end

View file

@ -9,9 +9,9 @@ module Admin
@form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.email_domain_blocks.no_domain_block_selected')
flash[:alert] = I18n.t('admin.domain_blocks.no_domain_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.domain_blocks.created_msg')
flash[:alert] = I18n.t('admin.domain_blocks.not_permitted')
else
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
end
@ -55,12 +55,8 @@ module Admin
def update
authorize :domain_block, :update?
@domain_block.update(update_params)
severity_changed = @domain_block.severity_changed?
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
if @domain_block.update(update_params)
DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
log_action :update, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else

View file

@ -19,7 +19,7 @@ module Admin
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.email_domain_blocks.no_email_domain_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
flash[:alert] = I18n.t('admin.email_domain_blocks.not_permitted')
ensure
redirect_to admin_email_domain_blocks_path
end

View file

@ -8,8 +8,6 @@ module Admin
before_action :set_dummy_import!, only: [:new]
ROWS_PROCESSING_LIMIT = 20_000
def new
authorize :domain_allow, :create?
end
@ -23,9 +21,11 @@ module Admin
authorize :domain_allow, :create?
begin
@import = Admin::Import.new(import_params)
return render :new unless @import.validate
parse_import_data!(export_headers)
@data.take(ROWS_PROCESSING_LIMIT).each do |row|
@data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
domain = row['#domain'].strip
next if DomainAllow.allowed?(domain)

View file

@ -8,8 +8,6 @@ module Admin
before_action :set_dummy_import!, only: [:new]
ROWS_PROCESSING_LIMIT = 20_000
def new
authorize :domain_block, :create?
end
@ -23,12 +21,14 @@ module Admin
authorize :domain_block, :create?
@import = Admin::Import.new(import_params)
return render :new unless @import.validate
parse_import_data!(export_headers)
@global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
@form = Form::DomainBlockBatch.new
@domain_blocks = @data.take(ROWS_PROCESSING_LIMIT).filter_map do |row|
@domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row|
domain = row['#domain'].strip
next if DomainBlock.rule_for(domain).present?

View file

@ -57,7 +57,7 @@ module Admin
end
def preload_delivery_failures!
warning_domains_map = DeliveryFailureTracker.warning_domains_map
warning_domains_map = DeliveryFailureTracker.warning_domains_map(@instances.map(&:domain))
@instances.each do |instance|
instance.failure_days = warning_domains_map[instance.domain]

View file

@ -3,7 +3,7 @@
module Admin
class RelaysController < BaseController
before_action :set_relay, except: [:index, :new, :create]
before_action :require_signatures_enabled!, only: [:new, :create, :enable]
before_action :warn_signatures_not_enabled!, only: [:new, :create, :enable]
def index
authorize :relay, :update?
@ -56,8 +56,8 @@ module Admin
params.require(:relay).permit(:inbox_url)
end
def require_signatures_enabled!
redirect_to admin_relays_path, alert: I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
def warn_signatures_not_enabled!
flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
end
end
end

View file

@ -16,6 +16,26 @@ class Api::BaseController < ApplicationController
protect_from_forgery with: :null_session
content_security_policy do |p|
# Set every directive that does not have a fallback
p.default_src :none
p.frame_ancestors :none
p.form_action :none
# Disable every directive with a fallback to cut on response size
p.base_uri false
p.font_src false
p.img_src false
p.style_src false
p.media_src false
p.frame_src false
p.manifest_src false
p.connect_src false
p.script_src false
p.child_src false
p.worker_src false
end
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
@ -129,7 +149,7 @@ class Api::BaseController < ApplicationController
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
response.headers['Cache-Control'] = 'private, no-store'
end
def disallow_unauthenticated_api_access?

View file

@ -54,12 +54,14 @@ class Api::V1::Admin::AccountsController < Api::BaseController
def approve
authorize @account.user, :approve?
@account.user.approve!
log_action :approve, @account.user
render json: @account, serializer: REST::Admin::AccountSerializer
end
def reject
authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
log_action :reject, @account.user
render_empty
end

View file

@ -40,10 +40,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
def update
authorize @domain_block, :update?
@domain_block.update(domain_block_params)
severity_changed = @domain_block.severity_changed?
@domain_block.save!
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
@domain_block.update!(domain_block_params)
DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
log_action :update, @domain_block
render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer
end

View file

@ -13,7 +13,7 @@ class Api::V1::FiltersController < Api::BaseController
def create
ApplicationRecord.transaction do
filter_category = current_account.custom_filters.create!(resource_params)
filter_category = current_account.custom_filters.create!(filter_params)
@filter = filter_category.keywords.create!(keyword_params)
end
@ -52,11 +52,11 @@ class Api::V1::FiltersController < Api::BaseController
end
def resource_params
params.permit(:phrase, :expires_in, :irreversible, context: [])
params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
end
def filter_params
resource_params.slice(:expires_in, :irreversible, :context)
resource_params.slice(:phrase, :expires_in, :irreversible, :context)
end
def keyword_params

View file

@ -3,11 +3,11 @@
class Api::V1::FollowedTagsController < Api::BaseController
TAGS_LIMIT = 100
before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }, except: :show
before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }
before_action :require_user!
before_action :set_results
after_action :insert_pagination_headers, only: :show
after_action :insert_pagination_headers
def index
render json: @results.map(&:tag), each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@results.map(&:tag), current_user&.account_id)
@ -43,7 +43,7 @@ class Api::V1::FollowedTagsController < Api::BaseController
end
def records_continue?
@results.size == limit_param(TAG_LIMIT)
@results.size == limit_param(TAGS_LIMIT)
end
def pagination_params(core_params)

View file

@ -40,7 +40,7 @@ class Api::V1::NotificationsController < Api::BaseController
private
def load_notifications
notifications = browserable_account_notifications.includes(from_account: :account_stat).to_a_paginated_by_id(
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_paginated_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View file

@ -12,7 +12,7 @@ class Api::V1::TagsController < Api::BaseController
end
def follow
TagFollow.create!(tag: @tag, account: current_account, rate_limit: true)
TagFollow.create_with(rate_limit: true).find_or_create_by!(tag: @tag, account: current_account)
render json: @tag, serializer: REST::TagSerializer
end

View file

@ -11,6 +11,8 @@ class Auth::PasswordsController < Devise::PasswordsController
super do |resource|
if resource.errors.empty?
resource.session_activations.destroy_all
resource.revoke_access!
end
end
end

View file

@ -57,8 +57,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
devise_parameter_sanitizer.permit(:sign_up) do |user_params|
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
end
end
@ -159,6 +159,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
response.headers['Cache-Control'] = 'private, no-store'
end
end

View file

@ -15,6 +15,10 @@ class Auth::SessionsController < Devise::SessionsController
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
content_security_policy only: :new do |p|
p.form_action(false)
end
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?

View file

@ -27,13 +27,13 @@ module AdminExportControllerConcern
params.require(:admin_import).permit(:data)
end
def import_data
Paperclip.io_adapters.for(@import.data).read
def import_data_path
params[:admin_import][:data].path
end
def parse_import_data!(default_headers)
data = CSV.parse(import_data, headers: true)
data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(default_headers[0])
data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
@data = data.reject(&:blank?)
end
end

View file

@ -58,7 +58,7 @@ module RateLimitHeaders
end
def api_throttle_data
most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] }
most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_key, value| value[:limit] - value[:count] }
request.env['rack.attack.throttle_data'][most_limited_type]
end

View file

@ -28,8 +28,8 @@ module SignatureVerification
end
class SignatureParamsTransformer < Parslet::Transform
rule(params: subtree(:p)) do
(p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
rule(params: subtree(:param)) do
(param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value }
end
rule(param: { key: simple(:key), value: simple(:val) }) do

View file

@ -63,7 +63,7 @@ class FollowerAccountsController < ApplicationController
if page_requested?
ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account, page: params.fetch(:page, 1)),
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
part_of: account_followers_url(@account),
next: next_page_url,
prev: prev_page_url,

View file

@ -66,7 +66,7 @@ class FollowingAccountsController < ApplicationController
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
type: :ordered,
size: @account.following_count,
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
part_of: account_following_index_url(@account),
next: next_page_url,
prev: prev_page_url

View file

@ -13,8 +13,8 @@ class MediaController < ApplicationController
before_action :allow_iframing, only: :player
before_action :set_pack, only: :player
content_security_policy only: :player do |p|
p.frame_ancestors(false)
content_security_policy only: :player do |policy|
policy.frame_ancestors(false)
end
def show

View file

@ -8,6 +8,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :set_pack
before_action :set_cache_headers
content_security_policy do |p|
p.form_action(false)
end
include Localized
private
@ -35,6 +39,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
response.headers['Cache-Control'] = 'private, no-store'
end
end

View file

@ -19,7 +19,7 @@ class Settings::BaseController < ApplicationController
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
response.headers['Cache-Control'] = 'private, no-store'
end
def require_not_suspended!

View file

@ -20,6 +20,10 @@ class StatusesCleanupController < ApplicationController
# Do nothing
end
def require_functional!
redirect_to edit_user_registration_path unless current_user.functional_or_moved?
end
private
def set_pack

View file

@ -17,8 +17,8 @@ class StatusesController < ApplicationController
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
content_security_policy only: :embed do |policy|
policy.frame_ancestors(false)
end
def show

View file

@ -65,7 +65,7 @@ class TagsController < ApplicationController
id: tag_url(@tag),
type: :ordered,
size: @tag.statuses.count,
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
items: @statuses.map { |status| ActivityPub::TagManager.instance.uri_for(status) }
)
end
end

View file

@ -23,19 +23,28 @@ module FormattingHelper
before_html = begin
if status.spoiler_text?
"<p><strong>#{I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)}</strong> #{h(status.spoiler_text)}</p><hr />"
else
''
tag.p do
tag.strong do
I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)
end
status.spoiler_text
end + tag.hr
end
end.html_safe # rubocop:disable Rails/OutputSafety
end
after_html = begin
if status.preloadable_poll
"<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>"
else
''
tag.p do
safe_join(
status.preloadable_poll.options.map do |o|
tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true)
end,
tag.br
)
end
end
end.html_safe # rubocop:disable Rails/OutputSafety
end
prerender_custom_emojis(
safe_join([before_html, html, after_html]),

View file

@ -190,12 +190,15 @@ module LanguagesHelper
ISO_639_3 = {
ast: ['Asturian', 'Asturianu'].freeze,
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
cnr: ['Montenegrin', 'crnogorski'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze,
kab: ['Kabyle', 'Taqbaylit'].freeze,
kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze,
ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
sco: ['Scots', 'Scots'].freeze,
sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,
smj: ['Lule Sami', 'Julevsámegiella'].freeze,
tok: ['Toki Pona', 'toki pona'].freeze,
zba: ['Balaibalan', 'باليبلن'].freeze,
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,

View file

@ -21,7 +21,7 @@ module StatusesHelper
def media_summary(status)
attachments = { image: 0, video: 0, audio: 0 }
status.media_attachments.each do |media|
status.ordered_media_attachments.each do |media|
if media.video?
attachments[:video] += 1
elsif media.audio?

View file

@ -102,7 +102,7 @@ export const addReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(addReactionRequest(announcementId, name, alreadyAdded));
}
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
}).catch(err => {
if (!alreadyAdded) {
@ -136,7 +136,7 @@ export const addReactionFail = (announcementId, name, error) => ({
export const removeReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(announcementId, name));
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
dispatch(removeReactionSuccess(announcementId, name));
}).catch(err => {
dispatch(removeReactionFail(announcementId, name, err));

View file

@ -93,12 +93,13 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
}
};
export function setComposeToStatus(status, text, spoiler_text) {
export function setComposeToStatus(status, text, spoiler_text, content_type) {
return{
type: COMPOSE_SET_STATUS,
status,
text,
spoiler_text,
content_type,
};
};

View file

@ -34,6 +34,11 @@ export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL';
export const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@ -101,7 +106,7 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => {
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch(fetchStatusSourceSuccess());
ensureComposeIsVisible(getState, routerHistory);
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text));
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type));
}).catch(error => {
dispatch(fetchStatusSourceFail(error));
});
@ -310,4 +315,36 @@ export function toggleStatusCollapse(id, isCollapsed) {
id,
isCollapsed,
};
}
};
export const translateStatus = id => (dispatch, getState) => {
dispatch(translateStatusRequest(id));
api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => {
dispatch(translateStatusSuccess(id, response.data));
}).catch(error => {
dispatch(translateStatusFail(id, error));
});
};
export const translateStatusRequest = id => ({
type: STATUS_TRANSLATE_REQUEST,
id,
});
export const translateStatusSuccess = (id, translation) => ({
type: STATUS_TRANSLATE_SUCCESS,
id,
translation,
});
export const translateStatusFail = (id, error) => ({
type: STATUS_TRANSLATE_FAIL,
id,
error,
});
export const undoStatusTranslation = id => ({
type: STATUS_TRANSLATE_UNDO,
id,
});

View file

@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { is } from 'immutable';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
import { debounce } from 'lodash';
@ -202,7 +201,7 @@ class Item extends React.PureComponent {
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.getAutoPlay();
const autoPlay = this.getAutoPlay();
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -216,6 +215,7 @@ class Item extends React.PureComponent {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
playsInline
loop
muted
/>

View file

@ -83,6 +83,7 @@ class Status extends ImmutablePureComponent {
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
@ -472,6 +473,10 @@ class Status extends ImmutablePureComponent {
this.node = c;
}
handleTranslate = () => {
this.props.onTranslate(this.props.status);
}
renderLoadingMediaGallery () {
return <div className='media-gallery' style={{ height: '110px' }} />;
}
@ -788,6 +793,7 @@ class Status extends ImmutablePureComponent {
mediaIcons={contentMediaIcons}
expanded={isExpanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
parseClick={parseClick}
disabled={!router}
tagLinks={settings.get('tag_misleading_links')}

View file

@ -212,11 +212,13 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus && isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}

View file

@ -1,11 +1,11 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, injectIntl } from 'react-intl';
import Permalink from './permalink';
import classnames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
import { autoPlayGif } from 'flavours/glitch/initial_state';
import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
const textMatchesTarget = (text, origin, host) => {
@ -62,13 +62,56 @@ const isLinkMisleading = (link) => {
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
};
export default class StatusContent extends React.PureComponent {
class TranslateButton extends React.PureComponent {
static propTypes = {
translation: ImmutablePropTypes.map,
onClick: PropTypes.func,
};
render () {
const { translation, onClick } = this.props;
if (translation) {
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
const languageName = language ? language[2] : translation.get('detected_source_language');
const provider = translation.get('provider');
return (
<div className='translate-button'>
<div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div>
<button className='link-button' onClick={onClick}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</div>
);
}
return (
<button className='status__content__read-more-button' onClick={onClick}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);
}
}
export default @injectIntl
class StatusContent extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.bool,
collapsed: PropTypes.bool,
onExpandedToggle: PropTypes.func,
onTranslate: PropTypes.func,
media: PropTypes.node,
extraMedia: PropTypes.node,
mediaIcons: PropTypes.arrayOf(PropTypes.string),
@ -77,6 +120,7 @@ export default class StatusContent extends React.PureComponent {
onUpdate: PropTypes.func,
tagLinks: PropTypes.bool,
rewriteMentions: PropTypes.string,
intl: PropTypes.object,
};
static defaultProps = {
@ -249,6 +293,10 @@ export default class StatusContent extends React.PureComponent {
}
}
handleTranslate = () => {
this.props.onTranslate();
}
setContentsRef = (c) => {
this.contentsNode = c;
}
@ -263,18 +311,24 @@ export default class StatusContent extends React.PureComponent {
disabled,
tagLinks,
rewriteMentions,
intl,
} = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language');
const content = { __html: status.get('contentHtml') };
const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };
const lang = status.get('language');
const lang = status.get('translation') ? intl.locale : status.get('language');
const classNames = classnames('status__content', {
'status__content--with-action': parseClick && !disabled,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
});
const translateButton = renderTranslate && (
<TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} />
);
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@ -350,11 +404,11 @@ export default class StatusContent extends React.PureComponent {
onMouseLeave={this.handleMouseLeave}
lang={lang}
/>
{!hidden && translateButton}
{media}
</div>
{extraMedia}
</div>
);
} else if (parseClick) {
@ -375,6 +429,7 @@ export default class StatusContent extends React.PureComponent {
onMouseLeave={this.handleMouseLeave}
lang={lang}
/>
{translateButton}
{media}
{extraMedia}
</div>
@ -395,6 +450,7 @@ export default class StatusContent extends React.PureComponent {
onMouseLeave={this.handleMouseLeave}
lang={lang}
/>
{translateButton}
{media}
{extraMedia}
</div>

View file

@ -27,8 +27,9 @@ store.dispatch(hydrateAction);
// check for deprecated local settings
store.dispatch(checkDeprecatedLocalSettings());
// load custom emojis
store.dispatch(fetchCustomEmojis());
if (initialState.meta.me) {
store.dispatch(fetchCustomEmojis());
}
const createIdentityContext = state => ({
signedIn: !!state.meta.me,

View file

@ -23,7 +23,9 @@ import {
deleteStatus,
hideStatus,
revealStatus,
editStatus
editStatus,
translateStatus,
undoStatusTranslation,
} from 'flavours/glitch/actions/statuses';
import {
initAddFilter,
@ -187,6 +189,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(editStatus(status.get('id'), history));
},
onTranslate (status) {
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id')));
} else {
dispatch(translateStatus(status.get('id')));
}
},
onDirect (account, router) {
dispatch(directCompose(account, router));
},

View file

@ -0,0 +1,37 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'flavours/glitch/components/icon';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account, onAuthorize, onReject } = this.props;
return (
<div className='follow-request-banner'>
<div className='follow-request-banner__message'>
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
</div>
<div className='follow-request-banner__action'>
<button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}>
<Icon id='check' fixedWidth />
<FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
</button>
<button type='button' className='button button-tertiary button--destructive' onClick={onReject}>
<Icon id='times' fixedWidth />
<FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
</button>
</div>
</div>
);
}
}

View file

@ -13,6 +13,7 @@ import Button from 'flavours/glitch/components/button';
import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container';
import FollowRequestNoteContainer from '../containers/follow_request_note_container';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
import { Helmet } from 'react-helmet';
@ -141,6 +142,17 @@ class Header extends ImmutablePureComponent {
}
}
handleShare = () => {
const { account } = this.props;
navigator.share({
text: `${titleFromAccount(account)}\n${account.get('note_plain')}`,
url: account.get('url'),
}).catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
}
render () {
const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity;
@ -303,6 +315,8 @@ class Header extends ImmutablePureComponent {
return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
<div className='account__header__image'>
<div className='account__header__info'>
{info}
@ -334,7 +348,9 @@ class Header extends ImmutablePureComponent {
<div className='account__header__tabs__name'>
<h1>
<span dangerouslySetInnerHTML={displayNameHtml} /> {badge}
<small>@{acct} {lockedIcon}</small>
<small>
<span>@{acct}</span> {lockedIcon}
</small>
</h1>
</div>

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import FollowRequestNote from '../components/follow_request_note';
import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
const mapDispatchToProps = (dispatch, { account }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(account.get('id')));
},
onReject () {
dispatch(rejectFollowRequest(account.get('id')));
},
});
export default connect(null, mapDispatchToProps)(FollowRequestNote);

View file

@ -2,7 +2,6 @@ import Blurhash from 'flavours/glitch/components/blurhash';
import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
import { isIOS } from 'flavours/glitch/is_mobile';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -105,11 +104,13 @@ export default class MediaItem extends ImmutablePureComponent {
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application'
src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={!isIOS() && autoPlayGif}
autoPlay={autoPlayGif}
playsInline
loop
muted
/>

View file

@ -25,7 +25,13 @@ const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
if (accountId === null) {
return {
isLoading: false,
isAccount: false,
statusIds: emptyList,
};
} else if (!accountId) {
return {
isLoading: true,
statusIds: emptyList,

View file

@ -0,0 +1,66 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
});
export default @injectIntl
class ActionBar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleLogout = () => {
this.props.onLogout();
}
render () {
const { intl } = this.props;
let menu = [];
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
</div>
</div>
);
}
}

View file

@ -356,10 +356,8 @@ class ComposeForm extends ImmutablePureComponent {
<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onChangeVisibility={onChangeVisibility}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
privacy={privacy}
isEditing={isEditing}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}

View file

@ -9,13 +9,13 @@ import IconButton from 'flavours/glitch/components/icon_button';
import DropdownMenu from './dropdown_menu';
// Utils.
import { isUserTouching } from 'flavours/glitch/is_mobile';
import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
// The component.
export default class ComposerOptionsDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
disabled: PropTypes.bool,
icon: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
@ -49,7 +49,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
const { onModalOpen } = this.props;
const { open } = this.state;
if (isUserTouching()) {
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {

View file

@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ActionBar from './action_bar';
import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
@ -10,11 +12,12 @@ export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
};
render () {
return (
<div className='drawer--account'>
<div className='navigation-bar'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={48} />
@ -28,11 +31,16 @@ export default class NavigationBar extends ImmutablePureComponent {
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
href={profileLink}
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
</div>
<div className='navigation-bar__actions'>
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);
};
}
}

View file

@ -10,8 +10,8 @@ import { connect } from 'react-redux';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from './text_icon_button';
import Dropdown from './dropdown';
import PrivacyDropdown from './privacy_dropdown';
import DropdownContainer from '../containers/dropdown_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -126,15 +126,11 @@ class ComposerOptions extends ImmutablePureComponent {
hasPoll: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func,
onChangeVisibility: PropTypes.func,
onChangeContentType: PropTypes.func,
onTogglePoll: PropTypes.func,
onDoodleOpen: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
privacy: PropTypes.string,
contentType: PropTypes.string,
resetFileKey: PropTypes.number,
spoiler: PropTypes.bool,
@ -195,12 +191,8 @@ class ComposerOptions extends ImmutablePureComponent {
hasPoll,
onChangeAdvancedOption,
onChangeContentType,
onChangeVisibility,
onTogglePoll,
onModalClose,
onModalOpen,
onToggleSpoiler,
privacy,
resetFileKey,
spoiler,
showContentTypeChoice,
@ -239,7 +231,7 @@ class ComposerOptions extends ImmutablePureComponent {
multiple
style={{ display: 'none' }}
/>
<Dropdown
<DropdownContainer
disabled={disabled || !allowMedia}
icon='paperclip'
items={[
@ -255,8 +247,6 @@ class ComposerOptions extends ImmutablePureComponent {
},
]}
onChange={this.handleClickAttach}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={formatMessage(messages.attach)}
/>
{!!pollLimits && (
@ -275,15 +265,9 @@ class ComposerOptions extends ImmutablePureComponent {
/>
)}
<hr />
<PrivacyDropdown
disabled={disabled || isEditing}
onChange={onChangeVisibility}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
value={privacy}
/>
<PrivacyDropdownContainer disabled={disabled || isEditing} />
{showContentTypeChoice && (
<Dropdown
<DropdownContainer
disabled={disabled}
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
items={[
@ -292,8 +276,6 @@ class ComposerOptions extends ImmutablePureComponent {
contentTypeItems.markdown,
]}
onChange={onChangeContentType}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={formatMessage(messages.content_type)}
value={contentType}
/>
@ -308,7 +290,7 @@ class ComposerOptions extends ImmutablePureComponent {
/>
)}
<LanguageDropdown />
<Dropdown
<DropdownContainer
disabled={disabled || isEditing}
icon='ellipsis-h'
items={advancedOptions ? [
@ -325,8 +307,6 @@ class ComposerOptions extends ImmutablePureComponent {
] : null}
onChange={onChangeAdvancedOption}
renderItemContents={this.renderToggleItemContents}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={formatMessage(messages.advanced_options_icon_title)}
closeOnChange={false}
/>

View file

@ -153,6 +153,7 @@ class PollForm extends ImmutablePureComponent {
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>

View file

@ -32,7 +32,7 @@ class PrivacyDropdown extends React.PureComponent {
};
render () {
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, intl: { formatMessage } } = this.props;
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
// We predefine our privacy items so that we can easily pick the
// dropdown icon later.
@ -75,6 +75,7 @@ class PrivacyDropdown extends React.PureComponent {
icon={(privacyItems[value] || {}).icon}
items={items}
onChange={onChange}
isUserTouching={isUserTouching}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={formatMessage(messages.change_privacy)}

View file

@ -144,33 +144,24 @@ class Search extends React.PureComponent {
return (
<div className='search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
ref={this.setRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</label>
<input
ref={this.setRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='search__icon'
onClick={this.handleClear}
role='button'
tabIndex='0'
>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} />
</div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}>
<SearchPopout />
</Overlay>
</div>

View file

@ -0,0 +1,12 @@
import { connect } from 'react-redux';
import { isUserTouching } from 'flavours/glitch/is_mobile';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import Dropdown from '../components/dropdown';
const mapDispatchToProps = dispatch => ({
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
});
export default connect(null, mapDispatchToProps)(Dropdown);

View file

@ -1,11 +1,30 @@
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import NavigationBar from '../components/navigation_bar';
import { logOut } from 'flavours/glitch/utils/log_out';
import { openModal } from 'flavours/glitch/actions/modal';
import { me } from 'flavours/glitch/initial_state';
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
export default connect(mapStateToProps)(NavigationBar);
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));

View file

@ -6,7 +6,7 @@ import {
addPoll,
removePoll,
} from 'flavours/glitch/actions/compose';
import { closeModal, openModal } from 'flavours/glitch/actions/modal';
import { openModal } from 'flavours/glitch/actions/modal';
function mapStateToProps (state) {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
@ -48,14 +48,6 @@ const mapDispatchToProps = (dispatch) => ({
onDoodleOpen() {
dispatch(openModal('DOODLE', { noEsc: true }));
},
onModalClose() {
dispatch(closeModal());
},
onModalOpen(props) {
dispatch(openModal('ACTIONS', props));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Options);

View file

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import PrivacyDropdown from '../components/privacy_dropdown';
import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { isUserTouching } from 'flavours/glitch/is_mobile';
const mapStateToProps = state => ({
value: state.getIn(['compose', 'privacy']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View file

@ -19,8 +19,6 @@ const emojiFilename = (filename) => {
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
};
const domParser = new DOMParser();
const emojifyTextNode = (node, customEmojis) => {
let str = node.textContent;
@ -39,7 +37,7 @@ const emojifyTextNode = (node, customEmojis) => {
}
}
let rend, replacement = '';
let rend, replacement = null;
if (i === str.length) {
break;
} else if (str[i] === ':') {
@ -51,7 +49,14 @@ const emojifyTextNode = (node, customEmojis) => {
// if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) {
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
replacement = document.createElement('img');
replacement.setAttribute('draggable', false);
replacement.setAttribute('class', 'emojione custom-emoji');
replacement.setAttribute('alt', shortname);
replacement.setAttribute('title', shortname);
replacement.setAttribute('src', filename);
replacement.setAttribute('data-original', customEmojis[shortname].url);
replacement.setAttribute('data-static', customEmojis[shortname].static_url);
return true;
}
return false;
@ -59,7 +64,12 @@ const emojifyTextNode = (node, customEmojis) => {
} else if (!useSystemEmojiFont) { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
replacement = document.createElement('img');
replacement.setAttribute('draggable', false);
replacement.setAttribute('class', 'emojione');
replacement.setAttribute('alt', match);
replacement.setAttribute('title', title);
replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`);
rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) {
@ -69,9 +79,8 @@ const emojifyTextNode = (node, customEmojis) => {
fragment.append(document.createTextNode(str.slice(0, i)));
if (replacement) {
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
fragment.append(replacement);
}
node.textContent = str.slice(0, i);
str = str.slice(rend);
}

View file

@ -47,7 +47,7 @@ class Explore extends React.PureComponent {
this.column = c;
}
render () {
render() {
const { intl, multiColumn, isSearching } = this.props;
const { signedIn } = this.context.identity;
@ -68,12 +68,22 @@ class Explore extends React.PureComponent {
{isSearching ? (
<SearchResults />
) : (
<React.Fragment>
<>
<div className='account__section-headline'>
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
{signedIn && <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
<NavLink exact to='/explore'>
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
</NavLink>
<NavLink exact to='/explore/tags'>
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
</NavLink>
<NavLink exact to='/explore/links'>
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
</NavLink>
{signedIn && (
<NavLink exact to='/explore/suggestions'>
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='For you' />
</NavLink>
)}
</div>
<Switch>
@ -87,7 +97,7 @@ class Explore extends React.PureComponent {
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
</Helmet>
</React.Fragment>
</>
)}
</div>
</Column>

View file

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
export default class Trends extends ImmutablePureComponent {
@ -36,7 +37,11 @@ export default class Trends extends ImmutablePureComponent {
return (
<div className='getting-started__trends'>
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
<h4>
<Link to={'/explore/tags'}>
<FormattedMessage id='trends.trending_now' defaultMessage='Trending now' />
</Link>
</h4>
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>

View file

@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following');
followButton = (
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} active={following} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
</button>
);

View file

@ -50,6 +50,8 @@ export default class LocalSettingsPage extends React.PureComponent {
<a
href={href}
className={finalClassName}
title={title}
aria-label={title}
>
{iconElem} <span>{title}</span>
</a>
@ -60,6 +62,8 @@ export default class LocalSettingsPage extends React.PureComponent {
role='button'
tabIndex='0'
className={finalClassName}
title={title}
aria-label={title}
>
{iconElem} <span>{title}</span>
</a>

View file

@ -34,6 +34,7 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func,
onTranslate: PropTypes.func.isRequired,
expanded: PropTypes.bool,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
@ -112,6 +113,11 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
handleTranslate = () => {
const { onTranslate, status } = this.props;
onTranslate(status);
}
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props;
@ -305,6 +311,7 @@ class DetailedStatus extends ImmutablePureComponent {
expanded={expanded}
collapsed={false}
onExpandedToggle={onToggleHidden}
onTranslate={this.handleTranslate}
parseClick={this.parseClick}
onUpdate={this.handleChildUpdate}
tagLinks={settings.get('tag_misleading_links')}

View file

@ -33,7 +33,9 @@ import {
deleteStatus,
editStatus,
hideStatus,
revealStatus
revealStatus,
translateStatus,
undoStatusTranslation,
} from 'flavours/glitch/actions/statuses';
import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks';
@ -437,6 +439,16 @@ class Status extends ImmutablePureComponent {
this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
}
handleTranslate = status => {
const { dispatch } = this.props;
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id')));
} else {
dispatch(translateStatus(status.get('id')));
}
}
handleBlockClick = (status) => {
const { dispatch } = this.props;
const account = status.get('account');
@ -666,6 +678,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
expanded={isExpanded}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}

View file

@ -99,7 +99,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
} else {
this.mediaQuery.removeListener(this.handleLayouteChange);
this.mediaQuery.removeListener(this.handleLayoutChange);
}
}
}

View file

@ -291,11 +291,11 @@ class FocalPointModal extends ImmutablePureComponent {
let descriptionLabel = null;
if (media.get('type') === 'audio') {
descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
} else if (media.get('type') === 'video') {
descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
} else {
descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
}
let ocrMessage = '';

View file

@ -7,6 +7,7 @@ import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
const Account = connect(state => ({
account: state.getIn(['accounts', me]),
@ -16,7 +17,14 @@ const Account = connect(state => ({
</Permalink>
));
export default @withRouter
const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() {
dispatch(openModal('CLOSED_REGISTRATIONS'));
},
});
export default @connect(null, mapDispatchToProps)
@withRouter
class Header extends React.PureComponent {
static contextTypes = {
@ -24,27 +32,44 @@ class Header extends React.PureComponent {
};
static propTypes = {
openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object,
};
render () {
const { signedIn } = this.context.identity;
const { location } = this.props;
const { location, openClosedRegistrationsModal } = this.props;
let content;
if (signedIn) {
content = (
<>
{location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish' defaultMessage='Publish' /></Link>}
{location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>}
<Account />
</>
);
} else {
let signupButton;
if (registrationsOpen) {
signupButton = (
<a href='/auth/sign_up' className='button button-tertiary'>
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
</a>
);
} else {
signupButton = (
<button className='button button-tertiary' onClick={openClosedRegistrationsModal}>
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
</button>
);
}
content = (
<>
<a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
<a href={registrationsOpen ? '/auth/sign_up' : 'https://joinmastodon.org/servers'} className='button button-tertiary'><FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /></a>
{signupButton}
</>
);
}

View file

@ -79,6 +79,7 @@
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {boolean} translation_enabled
* @property {object} local_settings
*/
@ -137,6 +138,7 @@ export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const translationEnabled = getMeta('translation_enabled');
export const languages = initialState?.languages;
// Glitch-soc-specific settings

View file

@ -1,7 +1,180 @@
import inherited from 'mastodon/locales/cs.json';
const messages = {
// No translations available.
'about.fork_disclaimer': 'Glitch-soc je svobodný software s otevřeným zdrojovým kódem založený na Mastodonu.',
'settings.layout_opts': 'Možnosti rozvržení',
'settings.layout': 'Rozložení:',
'layout.current_is': 'Nastavené rozložení je:',
'layout.auto': 'Automatické',
'layout.desktop': 'Desktop',
'layout.mobile': 'Mobil',
'layout.hint.auto': 'Vybrat rozložení automaticky v závislosti na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.',
'layout.hint.desktop': 'Použít vícesloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.',
'layout.hint.single': 'Použít jednosloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.',
'navigation_bar.app_settings': 'Nastavení aplikace',
'navigation_bar.featured_users': 'Vybraní uživatelé',
'endorsed_accounts_editor.endorsed_accounts': 'Vybrané účty',
'navigation_bar.info': 'Rozšířené informace',
'navigation_bar.misc': 'Různé',
'navigation_bar.keyboard_shortcuts': 'Klávesové zkratky',
'getting_started.onboarding': 'Ukaž mi to tu',
'onboarding.skip': 'Přeskočit',
'onboarding.next': 'Další',
'onboarding.done': 'Hotovo',
'onboarding.page_one.federation': '{domain} je \'instance\' Mastodonu. Mastodon je síť nezávislých serverů, které jsou spolu propojené do jedné velké sociální sítě. Těmto serverům říkáme instance.',
'onboarding.page_one.handle': 'Jste na instanci {domain}, takže celá adresa vašeho profilu je {handle}',
'onboarding.page_one.welcome': 'Vítá vás {domain}!',
'onboarding.page_two.compose': 'Příspěvky se píší v levém sloupci. Pomocí ikon pod příspěvkem k němu můžete připojit obrázky, změnit úroveň soukromí nebo přidat varování o obsahu.',
'onboarding.page_three.search': 'Pomocí vyhledávací lišty můžete hledat lidi nebo hashtagy. Pokud hledáte někoho z jiné instance, musíte použít celou adresu jeho profilu.',
'onboarding.page_three.profile': 'Upravte si svůj profil a nastavte si profilový obrázek, jméno, a krátký text o sobě. Naleznete tam i další možnosti nastavení.',
'onboarding.page_four.home': 'Domovská časová osa zobrazuje příspěvky od lidí, které sledujete.',
'onboarding.page_four.notifications': 'Notifikace se zobrazí, když s vámi někdo interaguje.',
'onboarding.page_five.public_timelines': 'Místní časová osa zobrazuje veřejné příspěvky všech uživatelů instance {domain}. Federovaná časová osa zobrazí příspěvky od všech, koho uživatelé instance {domain} sledují. Tyto veřejné časové osy jsou skvělý způsob, jak objevit nové lidi.',
'onboarding.page_six.almost_done': 'Skoro hotovo...',
'onboarding.page_six.github': 'Na serveru {domain} běží Glitchsoc. Glitchsoc je přátelský {fork} programu {Mastodon}, a je kompatibilní s jakoukoliv jinou mastodoní instancí nebo aplikací. Glitchsoc je zcela svobodný a má otevřený zdrojový kód. Na stránce {github} můžete hlásit chyby, žádat o nové funkce, nebo ke kódu vlastnoručně přispět.',
'onboarding.page_six.apps_available': 'Jsou dostupné {apps} pro iOS, Android i jiné platformy.',
'onboarding.page_six.various_app': 'mobilní aplikace',
'onboarding.page_six.appetoot': 'Veselé mastodonění!',
'settings.auto_collapse': 'Automaticky sbalit',
'settings.auto_collapse_all': 'Všechno',
'settings.auto_collapse_lengthy': 'Dlouhé příspěvky',
'settings.auto_collapse_media': 'Příspěvky s přílohami',
'settings.auto_collapse_notifications': 'Oznámení',
'settings.auto_collapse_reblogs': 'Boosty',
'settings.auto_collapse_replies': 'Odpovědi',
'settings.show_action_bar': 'Zobrazit ve sbalených příspěvcích tlačítka s akcemi',
'settings.close': 'Zavřít',
'settings.collapsed_statuses': 'Sbalené příspěvky',
'settings.confirm_boost_missing_media_description': 'Zobrazit potvrzovací dialog před boostnutím příspěvku s chybějícími popisky obrázků',
'boost_modal.missing_description': 'Příspěvek obsahuje obrázky bez popisků',
'settings.enable_collapsed': 'Povolit sbalené příspěvky',
'settings.enable_collapsed_hint': 'U sbalených příspěvků je část jejich obsahu skrytá, aby zabraly méně místa na obrazovce. (Tohle není stejná funkce jako varování o obsahu.)',
'settings.general': 'Obecné',
'settings.hicolor_privacy_icons': 'Barevné ikony soukromí',
'settings.hicolor_privacy_icons.hint': 'Zobrazit ikony úrovně soukromí příspěvků v jasných, snadno rozlišitelných barvách',
'settings.image_backgrounds': 'Obrázkové pozadí',
'settings.image_backgrounds_media': 'Náhled médií ve sbalených příspěvcích',
'settings.image_backgrounds_media_hint': 'Pokud jsou k příspěvku přiložena média, použije se první z nich jako pozadí',
'settings.image_backgrounds_users': 'Nastavit sbaleným příspěvkům obrázkové pozadí',
'settings.inline_preview_cards': 'Zobrazit v časové ose náhledy externích odkazů',
'settings.media': 'Média',
'settings.media_letterbox': 'Neořezávat obrázky',
'settings.media_letterbox_hint': 'Místo výřezu obrázku zobrazit obrázek celý, doplněný podle potřeby o prázdné okraje',
'settings.media_fullwidth': 'Zobrazit náhledy v plné šířce',
'settings.notifications_opts': 'Možnosti oznámení',
'settings.notifications.tab_badge': 'Zobrazit počet nepřečtených oznámení',
'settings.notifications.tab_badge.hint': 'Počet nepřečtených oznámení se viditelně zobrazí na hlavní stránce (pokud není seznam oznámení viditelný)',
'settings.notifications.favicon_badge': 'Zobrazit počet na ikoně serveru',
'settings.notifications.favicon_badge.hint': 'Zobrazí počet nepřečtených oznámení na ikoně serveru',
'settings.preferences': 'Předvolby',
'settings.rewrite_mentions': 'Přepsat zmínky v zobrazených příspěvcích',
'settings.rewrite_mentions_no': 'Nepřepisovat zmínky',
'settings.rewrite_mentions_acct': 'Přepsat uživatelským jménem a doménou (pokud je účet na jiném serveru)',
'settings.rewrite_mentions_username': 'Přepsat uživatelským jménem',
'settings.show_reply_counter': 'Zobrazit odhad počtu odpovědí',
'settings.status_icons': 'Ikony u příspěvků',
'settings.status_icons_language': 'Indikace jazyk',
'settings.status_icons_reply': 'Indikace odpovědi',
'settings.status_icons_local_only': 'Indikace lokálního příspěvku',
'settings.status_icons_media': 'Indikace obrázků a anket',
'settings.status_icons_visibility': 'Indikace úrovně soukromí',
'settings.tag_misleading_links': 'Označit zavádějící odkazy',
'settings.tag_misleading_links.hint': 'Zobrazit skutečný cíl u každého odkazu, který ho explicitně nezmiňuje',
'settings.wide_view': 'Široké sloupce (pouze v režimu Desktop)',
'settings.wide_view_hint': 'Sloupce se roztáhnout, aby lépe vyplnily dostupný prostor.',
'settings.navbar_under': 'Navigační lišta vespod (pouze v režimu Mobil)',
'settings.compose_box_opts': 'Editační pole',
'settings.always_show_spoilers_field': 'Vždy zobrazit pole pro varování o obsahu',
'settings.prepend_cw_re': 'Při odpovídání přidat před varování o obsahu “re: ”',
'settings.preselect_on_reply': 'Při odpovědi označit uživatelská jména',
'settings.preselect_on_reply_hint': 'Při odpovídání na konverzaci s více účastníky se jména všech kromě prvního označí, aby šla jednoduše smazat',
'settings.confirm_missing_media_description': 'Zobrazit potvrzovací dialog při odesílání příspěvku, ve kterém chybí popisky obrázků',
'settings.confirm_before_clearing_draft': 'Zobrazit potvrzovací dialog před přepsáním právě vytvářené zprávy',
'settings.show_content_type_choice': 'Zobrazit volbu formátu příspěvku',
'settings.side_arm': 'Vedlejší odesílací tlačítko:',
'settings.side_arm.none': 'Žádné',
'settings.side_arm_reply_mode': 'Při odpovídání na příspěvek by vedlejší odesílací tlačítko mělo:',
'settings.side_arm_reply_mode.keep': 'Použít svou nastavenou úroveň soukromí',
'settings.side_arm_reply_mode.copy': 'Použít úroveň soukromí příspěvku, na který odpovídáte',
'settings.side_arm_reply_mode.restrict': 'Zvýšit úroveň soukromí nejméně na úroveň příspěvku, na který odpovídáte',
'settings.content_warnings': 'Varování o obsahu',
'settings.content_warnings_shared_state': 'Zobrazit/schovat všechny kopie naráz',
'settings.content_warnings_shared_state_hint': 'Tlačítko varování o obsahu bude mít efekt na všechny kopie příspěvku naráz, stejně jako na běžném Mastodonu. Nebude pak možné automaticky sbalit jakoukoliv kopii příspěvku, která má rozbalené varování o obsahu',
'settings.content_warnings_media_outside': 'Zobrazit obrázky a videa mimo varování o obsahu',
'settings.content_warnings_media_outside_hint': 'Obrázky a videa z příspěvku s varováním o obsahu se zobrazí se separátním přepínačem zobrazení, stejně jako na běžném Mastodonu.',
'settings.content_warnings_unfold_opts': 'Možnosti automatického rozbalení',
'settings.enable_content_warnings_auto_unfold': 'Vždy rozbalit příspěvky označené varováním o obsahu',
'settings.deprecated_setting': 'Tato možnost se nyní nastavuje v {settings_page_link}',
'settings.shared_settings_link': 'předvolbách Mastodonu',
'settings.content_warnings_filter': 'Tato varování o obsahu automaticky nerozbalovat:',
'settings.content_warnings.regexp': 'Regulární výraz',
'settings.media_reveal_behind_cw': 'Automaticky zobrazit média označená varováním o obsahu',
'settings.pop_in_player': 'Povolit plovoucí okno přehrávače',
'settings.pop_in_position': 'Pozice plovoucího okna:',
'settings.pop_in_left': 'Vlevo',
'settings.pop_in_right': 'Vpravo',
'status.collapse': 'Sbalit',
'status.uncollapse': 'Rozbalit',
'status.in_reply_to': 'Tento příspěvek je odpověď',
'status.has_preview_card': 'Obsahuje náhled odkazu',
'status.has_pictures': 'Obsahuje obrázky',
'status.is_poll': 'Tento příspěvek je anketa',
'status.has_video': 'Obsahuje video',
'status.has_audio': 'Obsahuje audio',
'status.local_only': 'Viditelné pouze z vaší instance',
'media_gallery.sensitive': 'Citlivý obsah',
'favourite_modal.combo': 'Příště můžete pro přeskočení stisknout {combo}',
'home.column_settings.show_direct': 'Zobrazit přímé zprávy',
'notification_purge.start': 'Čistící režim',
'notifications.mark_as_read': 'Označit všechna oznámení jako přečtená',
'notification.markForDeletion': 'Označit pro smazání',
'notifications.clear': 'Vymazat všechna oznámení',
'notifications.marked_clear_confirmation': 'Určitě chcete trvale smazat všechna vybraná oznámení?',
'notifications.marked_clear': 'Smazat vybraná oznámení',
'notification_purge.btn_all': 'Vybrat\nvše',
'notification_purge.btn_none': 'Nevybrat\nnic',
'notification_purge.btn_invert': 'Obrátit\nvýběr',
'notification_purge.btn_apply': 'Smazat\nvybrané',
'compose.attach.upload': 'Nahrát soubor',
'compose.attach.doodle': 'Něco namalovat',
'compose.attach': 'Připojit...',
'advanced_options.local-only.short': 'Lokální příspěvek',
'advanced_options.local-only.long': 'Neposílat na jiné servery',
'advanced_options.local-only.tooltip': 'Tento příspěvek je pouze lokální',
'advanced_options.icon_title': 'Pokročilá nastavení',
'advanced_options.threaded_mode.short': 'Režim vlákna',
'advanced_options.threaded_mode.long': 'Po odeslání automaticky otevře pole pro odpověď',
'advanced_options.threaded_mode.tooltip': 'Režim vlákna je zapnutý',
'home.column_settings.advanced': 'Pokročilé',
'home.column_settings.filter_regex': 'Filtrovat podle regulárních výrazů',
'compose_form.poll.single_choice': 'Povolit jednu odpověď',
'compose_form.poll.multiple_choices': 'Povolit více odpovědí',
'compose.content-type.plain': 'Prostý text',
'content-type.change': 'Formát příspěvku',
'compose_form.spoiler': 'Přidat varování o obsahu',
'direct.group_by_conversations': 'Seskupit do konverzací',
'column.toot': 'Příspěvky a odpovědi',
'confirmation_modal.do_not_ask_again': 'Příště se už neptat',
'keyboard_shortcuts.bookmark': 'Přidat do záložek',
'keyboard_shortcuts.toggle_collapse': 'Sbalit/rozbalit příspěvek',
'keyboard_shortcuts.secondary_toot': 'Odeslat příspěvek s druhotným nastavením soukromí',
'column.subheading': 'Různé',
};
export default Object.assign({}, inherited, messages);

View file

@ -6,6 +6,14 @@ en:
skins:
glitch:
default: Default
cs:
flavours:
glitch:
description: Výchozí rozhraní instancí GlitchSoc.
name: Glitch
skins:
glitch:
default: Výchozí
pl:
flavours:
glitch:

View file

@ -42,6 +42,18 @@ function main() {
minute: 'numeric',
});
const dateFormat = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeFormat: false,
});
const timeFormat = new Intl.DateTimeFormat(locale, {
timeStyle: 'short',
hour12: false,
});
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
content.innerHTML = emojify(content.innerHTML);
});
@ -54,6 +66,32 @@ function main() {
content.textContent = formattedDate;
});
const isToday = date => {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
let formattedContent;
if (isToday(datetime)) {
const formattedTime = timeFormat.format(datetime);
formattedContent = todayFormat.format({ time: formattedTime });
} else {
formattedContent = dateFormat.format(datetime);
}
content.title = formattedContent;
content.textContent = formattedContent;
});
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
const now = new Date();

View file

@ -1,4 +1,5 @@
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
import { Map as ImmutableMap } from 'immutable';
export const normalizeForLookup = str => str.toLowerCase();
@ -7,6 +8,8 @@ const initialState = ImmutableMap();
export default function accountsMap(state = initialState, action) {
switch(action.type) {
case ACCOUNT_LOOKUP_FAIL:
return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
case ACCOUNT_IMPORT:
return state.set(normalizeForLookup(action.account.acct), action.account.id);
case ACCOUNTS_IMPORT:

View file

@ -421,8 +421,10 @@ export default function compose(state = initialState, action) {
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
if (action.status.get('language')) {
if (action.status.get('language') && !action.status.has('translation')) {
map.set('language', action.status.get('language'));
} else {
map.set('language', state.get('default_language'));
}
if (action.status.get('spoiler_text').length > 0) {
@ -536,6 +538,8 @@ export default function compose(state = initialState, action) {
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else if (action.id === state.get('id')) {
return state.set('id', null);
} else {
return state;
}
@ -578,6 +582,10 @@ export default function compose(state = initialState, action) {
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
map.set('spoiler_text', action.status.get('spoiler_text'));
if (map.get('media_attachments').size >= 1) {
map.set('sensitive', true);
}
} else {
map.set('spoiler', false);
map.set('spoiler_text', '');
@ -595,6 +603,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('id', action.status.get('id'));
map.set('text', action.text);
map.set('content_type', action.content_type || 'text/plain');
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));

View file

@ -1,3 +1,6 @@
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST,
@ -12,6 +15,8 @@ import {
ACCOUNT_PIN_SUCCESS,
ACCOUNT_UNPIN_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from 'flavours/glitch/actions/accounts';
import {
DOMAIN_BLOCK_SUCCESS,
@ -44,6 +49,12 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) {
switch(action.type) {
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
case FOLLOW_REQUEST_REJECT_SUCCESS:
return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
case ACCOUNT_FOLLOW_REQUEST:
return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL:

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