Merge pull request #2392 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes into glitch-soc
th-downstream
Claire 1 year ago committed by GitHub
commit ee02b10e06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,10 +4,6 @@ FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye
# Install Rails # Install Rails
# RUN gem install rails webdrivers # RUN gem install rails webdrivers
# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
# The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"
ARG NODE_VERSION="16" ARG NODE_VERSION="16"
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1" RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"

@ -0,0 +1,49 @@
{
"name": "Mastodon on GitHub Codespaces",
"dockerComposeFile": "../docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},
"runServices": ["app", "db", "redis"],
"forwardPorts": [3000, 4000],
"portsAttributes": {
"3000": {
"label": "web",
"onAutoForward": "notify"
},
"4000": {
"label": "stream",
"onAutoForward": "silent"
}
},
"otherPortsAttributes": {
"onAutoForward": "silent"
},
"remoteEnv": {
"LOCAL_DOMAIN": "${localEnv:CODESPACE_NAME}-3000.app.github.dev",
"LOCAL_HTTPS": "true",
"STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev",
"DISABLE_FORGERY_REQUEST_PROTECTION": "true",
"ES_ENABLED": "",
"LIBRE_TRANSLATE_ENDPOINT": ""
},
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"postCreateCommand": ".devcontainer/post-create.sh",
"waitFor": "postCreateCommand",
"customizations": {
"vscode": {
"settings": {},
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
}
}
}

@ -1,5 +1,5 @@
{ {
"name": "Mastodon", "name": "Mastodon on local machine",
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"service": "app", "service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
@ -8,13 +8,23 @@
"ghcr.io/devcontainers/features/sshd:1": {} "ghcr.io/devcontainers/features/sshd:1": {}
}, },
"runServices": ["app", "db", "redis"],
"forwardPorts": [3000, 4000], "forwardPorts": [3000, 4000],
"containerEnv": { "portsAttributes": {
"ES_ENABLED": "", "3000": {
"LIBRE_TRANSLATE_ENDPOINT": "" "label": "web",
"onAutoForward": "notify",
"requireLocalPort": true
},
"4000": {
"label": "stream",
"onAutoForward": "silent",
"requireLocalPort": true
}
},
"otherPortsAttributes": {
"onAutoForward": "silent"
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",

@ -25,6 +25,7 @@ services:
command: sleep infinity command: sleep infinity
ports: ports:
- '127.0.0.1:3000:3000' - '127.0.0.1:3000:3000'
- '127.0.0.1:3035:3035'
- '127.0.0.1:4000:4000' - '127.0.0.1:4000:4000'
networks: networks:
- external_network - external_network

@ -8,7 +8,9 @@ on:
type: boolean type: boolean
push_to_images: push_to_images:
type: string type: string
version_suffix: version_prerelease:
type: string
version_metadata:
type: string type: string
flavor: flavor:
type: string type: string
@ -74,8 +76,6 @@ jobs:
if: ${{ inputs.push_to_images != '' }} if: ${{ inputs.push_to_images != '' }}
with: with:
images: ${{ inputs.push_to_images }} images: ${{ inputs.push_to_images }}
# Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release
flavor: ${{ inputs.flavor }} flavor: ${{ inputs.flavor }}
tags: ${{ inputs.tags }} tags: ${{ inputs.tags }}
labels: ${{ inputs.labels }} labels: ${{ inputs.labels }}
@ -83,7 +83,9 @@ jobs:
- uses: docker/build-push-action@v4 - uses: docker/build-push-action@v4
with: with:
context: . context: .
build-args: MASTODON_VERSION_SUFFIX=${{ inputs.version_suffix }} build-args: |
MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }}
MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
platforms: ${{ inputs.platforms }} platforms: ${{ inputs.platforms }}
provenance: false provenance: false
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}

@ -16,9 +16,9 @@ jobs:
env: env:
TZ: Etc/UTC TZ: Etc/UTC
run: | run: |
echo mastodon_version_suffix=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
outputs: outputs:
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }} prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }}
build-image: build-image:
needs: compute-suffix needs: compute-suffix
@ -28,8 +28,7 @@ jobs:
use_native_arm64_builder: false use_native_arm64_builder: false
push_to_images: | push_to_images: |
ghcr.io/${{ github.repository_owner }}/mastodon ghcr.io/${{ github.repository_owner }}/mastodon
# The `-` is important here, result will be v4.1.2-nightly.2022-03-05 version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }}
version_suffix: -${{ needs.compute-suffix.outputs.suffix }}
labels: | labels: |
org.opencontainers.image.description=Nightly build image used for testing purposes org.opencontainers.image.description=Nightly build image used for testing purposes
flavor: | flavor: |
@ -37,5 +36,5 @@ jobs:
tags: | tags: |
type=raw,value=edge type=raw,value=edge
type=raw,value=nightly type=raw,value=nightly
type=schedule,pattern=${{ needs.compute-suffix.outputs.suffix }} type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }}
secrets: inherit secrets: inherit

@ -21,9 +21,9 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- id: version_vars - id: version_vars
run: | run: |
echo mastodon_version_suffix=+pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
outputs: outputs:
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }} metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}
build-image: build-image:
needs: compute-suffix needs: compute-suffix
@ -33,7 +33,7 @@ jobs:
use_native_arm64_builder: false use_native_arm64_builder: false
push_to_images: | push_to_images: |
ghcr.io/${{ github.repository_owner }}/mastodon ghcr.io/${{ github.repository_owner }}/mastodon
version_suffix: ${{ needs.compute-suffix.outputs.suffix }} version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
flavor: | flavor: |
latest=auto latest=auto
tags: | tags: |

@ -16,6 +16,8 @@ jobs:
use_native_arm64_builder: false use_native_arm64_builder: false
push_to_images: | push_to_images: |
ghcr.io/${{ github.repository_owner }}/mastodon ghcr.io/${{ github.repository_owner }}/mastodon
# Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') }} latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') }}
tags: | tags: |

@ -101,7 +101,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452)) - **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452))
- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378)) - **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378))
- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874)) - **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874))
- **Change local and federated timelines to be in a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247)) - **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034)) - **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034))
- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751)) - **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751))
- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310)) - **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310))
@ -189,6 +189,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073)) - **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073))
- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218)) - **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218))
- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392)) - **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808))
- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500)) - Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409)) - Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375)) - Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375))

@ -42,8 +42,8 @@ RUN apt-get update && \
FROM node:${NODE_VERSION} FROM node:${NODE_VERSION}
# Use those args to specify your own version flags & suffixes # Use those args to specify your own version flags & suffixes
ARG MASTODON_VERSION_FLAGS="" ARG MASTODON_VERSION_PRERELEASE=""
ARG MASTODON_VERSION_SUFFIX="" ARG MASTODON_VERSION_METADATA=""
ARG UID="991" ARG UID="991"
ARG GID="991" ARG GID="991"
@ -89,8 +89,8 @@ ENV RAILS_ENV="production" \
NODE_ENV="production" \ NODE_ENV="production" \
RAILS_SERVE_STATIC_FILES="true" \ RAILS_SERVE_STATIC_FILES="true" \
BIND="0.0.0.0" \ BIND="0.0.0.0" \
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \ MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}" MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"
# Set the run user # Set the run user
USER mastodon USER mastodon

@ -109,7 +109,7 @@ GEM
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.4) addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
airbrussh (1.4.1) airbrussh (1.4.1)
@ -124,8 +124,8 @@ GEM
attr_required (1.0.1) attr_required (1.0.1)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.793.0) aws-partitions (1.809.0)
aws-sdk-core (3.180.3) aws-sdk-core (3.181.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
@ -133,8 +133,8 @@ GEM
aws-sdk-kms (1.71.0) aws-sdk-kms (1.71.0)
aws-sdk-core (~> 3, >= 3.177.0) aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.132.1) aws-sdk-s3 (1.133.0)
aws-sdk-core (~> 3, >= 3.179.0) aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6) aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0) aws-sigv4 (1.6.0)
@ -203,7 +203,7 @@ GEM
activesupport activesupport
cbor (0.5.9.6) cbor (0.5.9.6)
charlock_holmes (0.7.7) charlock_holmes (0.7.7)
chewy (7.3.3) chewy (7.3.4)
activesupport (>= 5.2) activesupport (>= 5.2)
elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl elasticsearch-dsl
@ -324,7 +324,7 @@ GEM
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (1.1.0) globalid (1.1.0)
activesupport (>= 5.0) activesupport (>= 5.0)
haml (6.1.1) haml (6.1.2)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
@ -333,7 +333,7 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.49.3) haml_lint (0.50.0)
haml (>= 4.0, < 6.2) haml (>= 4.0, < 6.2)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
@ -482,7 +482,7 @@ GEM
nokogiri (1.15.4) nokogiri (1.15.4)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.0) oj (3.16.1)
omniauth (2.1.1) omniauth (2.1.1)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
@ -519,7 +519,7 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.3) pg (1.5.4)
pghero (3.3.3) pghero (3.3.3)
activerecord (>= 6) activerecord (>= 6)
posix-spawn (0.3.15) posix-spawn (0.3.15)
@ -731,7 +731,7 @@ GEM
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stoplight (3.0.1) stoplight (3.0.2)
redlock (~> 1.0) redlock (~> 1.0)
strong_migrations (0.8.0) strong_migrations (0.8.0)
activerecord (>= 5.2) activerecord (>= 5.2)
@ -795,7 +795,7 @@ GEM
webfinger (1.2.0) webfinger (1.2.0)
activesupport activesupport
httpclient (>= 2.4) httpclient (>= 2.4)
webmock (3.18.1) webmock (3.19.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)

@ -1,4 +1,4 @@
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
stream: env PORT=4000 yarn run start stream: env PORT=4000 yarn run start
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 webpack: bin/webpack-dev-server

@ -21,12 +21,13 @@ class AccountsIndex < Chewy::Index
analyzer: { analyzer: {
natural: { natural: {
tokenizer: 'uax_url_email', tokenizer: 'standard',
filter: %w( filter: %w(
english_possessive_stemmer
lowercase lowercase
asciifolding asciifolding
cjk_width cjk_width
elision
english_possessive_stemmer
english_stop english_stop
english_stemmer english_stemmer
), ),
@ -62,6 +63,6 @@ class AccountsIndex < Chewy::Index
field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at })
field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
end end
end end

@ -0,0 +1,56 @@
# frozen_string_literal: true
class PublicStatusesIndex < Chewy::Index
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: {
english_stop: {
type: 'stop',
stopwords: '_english_',
},
english_stemmer: {
type: 'stemmer',
language: 'english',
},
english_possessive_stemmer: {
type: 'stemmer',
language: 'possessive_english',
},
},
analyzer: {
verbatim: {
tokenizer: 'uax_url_email',
filter: %w(lowercase),
},
content: {
tokenizer: 'standard',
filter: %w(
lowercase
asciifolding
cjk_width
elision
english_possessive_stemmer
english_stop
english_stemmer
),
},
},
}
index_scope ::Status.unscoped
.kept
.indexable
.includes(:media_attachments, :preloadable_poll, :preview_cards)
root date_detection: false do
field(:id, type: 'long')
field(:account_id, type: 'long')
field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
field(:language, type: 'keyword')
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date')
end
end

@ -1,31 +1,38 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusesIndex < Chewy::Index class StatusesIndex < Chewy::Index
include FormattingHelper
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: { filter: {
english_stop: { english_stop: {
type: 'stop', type: 'stop',
stopwords: '_english_', stopwords: '_english_',
}, },
english_stemmer: { english_stemmer: {
type: 'stemmer', type: 'stemmer',
language: 'english', language: 'english',
}, },
english_possessive_stemmer: { english_possessive_stemmer: {
type: 'stemmer', type: 'stemmer',
language: 'possessive_english', language: 'possessive_english',
}, },
}, },
analyzer: { analyzer: {
content: { verbatim: {
tokenizer: 'uax_url_email', tokenizer: 'uax_url_email',
filter: %w(lowercase),
},
content: {
tokenizer: 'standard',
filter: %w( filter: %w(
english_possessive_stemmer
lowercase lowercase
asciifolding asciifolding
cjk_width cjk_width
elision
english_possessive_stemmer
english_stop english_stop
english_stemmer english_stemmer
), ),
@ -33,43 +40,15 @@ class StatusesIndex < Chewy::Index
}, },
} }
# We do not use delete_if option here because it would call a method that we index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preview_cards, :local_mentioned, :local_favorited, :local_reblogged, :local_bookmarked, preloadable_poll: :local_voters), delete_if: ->(status) { status.searchable_by.empty? }
# expect to be called with crutches without crutches, causing n+1 queries
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :favourites do |collection|
data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :reblogs do |collection|
data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :bookmarks do |collection|
data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :votes do |collection|
data = ::PollVote.joins(:poll).where(poll: { status_id: collection.map(&:id) }).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
root date_detection: false do root date_detection: false do
field :id, type: 'long' field(:id, type: 'long')
field :account_id, type: 'long' field(:account_id, type: 'long')
field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
field :text, type: 'text', value: ->(status) { status.searchable_text } do field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
field :stemmed, type: 'text', analyzer: 'content' field(:language, type: 'keyword')
end field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date')
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
end end
end end

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Admin
class SoftwareUpdatesController < BaseController
before_action :check_enabled!
def index
authorize :software_update, :index?
@software_updates = SoftwareUpdate.all.sort_by(&:gem_version)
end
private
def check_enabled!
not_found unless SoftwareUpdate.check_enabled?
end
end
end

@ -30,6 +30,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
:bot, :bot,
:discoverable, :discoverable,
:hide_collections, :hide_collections,
:indexable,
fields_attributes: [:name, :value] fields_attributes: [:name, :value]
) )
end end

@ -8,7 +8,15 @@ class Api::V1::Statuses::TranslationsController < Api::BaseController
before_action :set_translation before_action :set_translation
rescue_from TranslationService::NotConfiguredError, with: :not_found rescue_from TranslationService::NotConfiguredError, with: :not_found
rescue_from TranslationService::UnexpectedResponseError, TranslationService::QuotaExceededError, TranslationService::TooManyRequestsError, with: :service_unavailable rescue_from TranslationService::UnexpectedResponseError, with: :service_unavailable
rescue_from TranslationService::QuotaExceededError do
render json: { error: I18n.t('translation.errors.quota_exceeded') }, status: 503
end
rescue_from TranslationService::TooManyRequestsError do
render json: { error: I18n.t('translation.errors.too_many_requests') }, status: 503
end
def create def create
render json: @translation, serializer: REST::TranslationSerializer render json: @translation, serializer: REST::TranslationSerializer

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Timelines::TagController < Api::BaseController class Api::V1::Timelines::TagController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
before_action :load_tag before_action :load_tag
after_action :insert_pagination_headers, unless: -> { @statuses.empty? } after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
@ -12,6 +13,10 @@ class Api::V1::Timelines::TagController < Api::BaseController
private private
def require_auth?
!Setting.timeline_preview
end
def load_tag def load_tag
@tag = Tag.find_normalized(params[:id]) @tag = Tag.find_normalized(params[:id])
end end

@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
include DomainControlHelper include DomainControlHelper
include ThemingConcern include ThemingConcern
include DatabaseHelper include DatabaseHelper
include AuthorizedFetchHelper
helper_method :current_account helper_method :current_account
helper_method :current_session helper_method :current_session
@ -53,10 +54,6 @@ class ApplicationController < ActionController::Base
private private
def authorized_fetch_mode?
ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.limited_federation_mode
end
def public_fetch_mode? def public_fetch_mode?
!authorized_fetch_mode? !authorized_fetch_mode?
end end

@ -119,6 +119,8 @@ module SignatureVerification
private private
def fail_with!(message, **options) def fail_with!(message, **options)
Rails.logger.warn { "Signature verification failed: #{message}" }
@signature_verification_failure_reason = { error: message }.merge(options) @signature_verification_failure_reason = { error: message }.merge(options)
@signed_request_actor = nil @signed_request_actor = nil
end end

@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
private private
def account_params def account_params
params.require(:account).permit(:discoverable, :unlocked, :show_collections, settings: UserSettings.keys) params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys)
end end
def set_account def set_account

@ -0,0 +1,11 @@
# frozen_string_literal: true
module AuthorizedFetchHelper
def authorized_fetch_mode?
ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch } == 'true' || Rails.configuration.x.limited_federation_mode
end
def authorized_fetch_overridden?
ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.limited_federation_mode
end
end

@ -1,28 +0,0 @@
// This file will be loaded on public pages, regardless of theme.
import 'packs/public-path';
import { delegate } from '@rails/ujs';
const getProfileAvatarAnimationHandler = (swapTo) => {
//animate avatar gifs on the profile page when moused over
return ({ target }) => {
const swapSrc = target.getAttribute(swapTo);
//only change the img source if autoplay is off and the image src is actually different
if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
target.src = swapSrc;
}
};
};
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
delegate(document, '#account_header', 'change', ({ target }) => {
const header = document.querySelector('.card .card__img img');
const [file] = target.files || [];
const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
header.src = url;
});

@ -2,21 +2,6 @@
import 'packs/public-path'; import 'packs/public-path';
import { delegate } from '@rails/ujs'; import { delegate } from '@rails/ujs';
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../mastodon/features/emoji/emoji';
delegate(document, '#account_display_name', 'input', ({ target }) => {
const name = document.querySelector('.card .display-name strong');
if (name) {
if (target.value) {
name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
} else {
name.textContent = name.textContent = target.dataset.default;
}
}
});
delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => { delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
const avatar = document.getElementById(target.id + '-preview'); const avatar = document.getElementById(target.id + '-preview');
@ -26,18 +11,6 @@ delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
avatar.src = url; avatar.src = url;
}); });
delegate(document, '#account_locked', 'change', ({ target }) => {
const lock = document.querySelector('.card .display-name i');
if (lock) {
if (target.checked) {
delete lock.dataset.hidden;
} else {
lock.dataset.hidden = 'true';
}
}
});
delegate(document, '.input-copy input', 'click', ({ target }) => { delegate(document, '.input-copy input', 'click', ({ target }) => {
target.focus(); target.focus();
target.select(); target.select();

@ -13,8 +13,8 @@ pack:
mailer: mailer:
filename: mailer.js filename: mailer.js
stylesheet: true stylesheet: true
modal: public.js modal:
public: public.js public:
settings: settings.js settings: settings.js
sign_up: sign_up:
share: share:

@ -1,11 +1,16 @@
import api from '../api'; import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL = 'REBLOG_FAIL'; export const REBLOG_FAIL = 'REBLOG_FAIL';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
@ -26,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS'; export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL = 'PIN_FAIL'; export const PIN_FAIL = 'PIN_FAIL';
@ -259,8 +268,10 @@ export function fetchReblogs(id) {
dispatch(fetchReblogsRequest(id)); dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(fetchReblogsSuccess(id, response.data)); dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchReblogsFail(id, error)); dispatch(fetchReblogsFail(id, error));
}); });
@ -274,17 +285,62 @@ export function fetchReblogsRequest(id) {
}; };
} }
export function fetchReblogsSuccess(id, accounts) { export function fetchReblogsSuccess(id, accounts, next) {
return { return {
type: REBLOGS_FETCH_SUCCESS, type: REBLOGS_FETCH_SUCCESS,
id, id,
accounts, accounts,
next,
}; };
} }
export function fetchReblogsFail(id, error) { export function fetchReblogsFail(id, error) {
return { return {
type: REBLOGS_FETCH_FAIL, type: REBLOGS_FETCH_FAIL,
id,
error,
};
}
export function expandReblogs(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandReblogsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandReblogsFail(id, error)));
};
}
export function expandReblogsRequest(id) {
return {
type: REBLOGS_EXPAND_REQUEST,
id,
};
}
export function expandReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandReblogsFail(id, error) {
return {
type: REBLOGS_EXPAND_FAIL,
id,
error, error,
}; };
} }
@ -294,8 +350,10 @@ export function fetchFavourites(id) {
dispatch(fetchFavouritesRequest(id)); dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(fetchFavouritesSuccess(id, response.data)); dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchFavouritesFail(id, error)); dispatch(fetchFavouritesFail(id, error));
}); });
@ -309,17 +367,62 @@ export function fetchFavouritesRequest(id) {
}; };
} }
export function fetchFavouritesSuccess(id, accounts) { export function fetchFavouritesSuccess(id, accounts, next) {
return { return {
type: FAVOURITES_FETCH_SUCCESS, type: FAVOURITES_FETCH_SUCCESS,
id, id,
accounts, accounts,
next,
}; };
} }
export function fetchFavouritesFail(id, error) { export function fetchFavouritesFail(id, error) {
return { return {
type: FAVOURITES_FETCH_FAIL, type: FAVOURITES_FETCH_FAIL,
id,
error,
};
}
export function expandFavourites(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFavouritesRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandFavouritesFail(id, error)));
};
}
export function expandFavouritesRequest(id) {
return {
type: FAVOURITES_EXPAND_REQUEST,
id,
};
}
export function expandFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandFavouritesFail(id, error) {
return {
type: FAVOURITES_EXPAND_FAIL,
id,
error, error,
}; };
} }

@ -792,6 +792,7 @@ class Status extends ImmutablePureComponent {
tabIndex={0} tabIndex={0}
data-featured={featured ? 'true' : null} data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
> >
{!muted && prepend} {!muted && prepend}

@ -1,11 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
injectIntl,
FormattedMessage,
defineMessages,
} from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
@ -52,6 +48,16 @@ class Search extends PureComponent {
options: [], options: [],
}; };
defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
];
setRef = c => { setRef = c => {
this.searchForm = c; this.searchForm = c;
}; };
@ -100,7 +106,7 @@ class Search extends PureComponent {
handleKeyDown = (e) => { handleKeyDown = (e) => {
const { selectedOption } = this.state; const { selectedOption } = this.state;
const options = this._getOptions(); const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
switch(e.key) { switch(e.key) {
case 'Escape': case 'Escape':
@ -131,10 +137,9 @@ class Search extends PureComponent {
if (selectedOption === -1) { if (selectedOption === -1) {
this._submit(); this._submit();
} else if (options.length > 0) { } else if (options.length > 0) {
options[selectedOption].action(); options[selectedOption].action(e);
} }
this._unfocus();
break; break;
case 'Delete': case 'Delete':
if (selectedOption > -1 && options.length > 0) { if (selectedOption > -1 && options.length > 0) {
@ -161,6 +166,7 @@ class Search extends PureComponent {
router.history.push(`/tags/${query}`); router.history.push(`/tags/${query}`);
onClickSearchResult(query, 'hashtag'); onClickSearchResult(query, 'hashtag');
this._unfocus();
}; };
handleAccountClick = () => { handleAccountClick = () => {
@ -171,6 +177,7 @@ class Search extends PureComponent {
router.history.push(`/@${query}`); router.history.push(`/@${query}`);
onClickSearchResult(query, 'account'); onClickSearchResult(query, 'account');
this._unfocus();
}; };
handleURLClick = () => { handleURLClick = () => {
@ -178,6 +185,7 @@ class Search extends PureComponent {
const { onOpenURL } = this.props; const { onOpenURL } = this.props;
onOpenURL(router.history); onOpenURL(router.history);
this._unfocus();
}; };
handleStatusSearch = () => { handleStatusSearch = () => {
@ -196,6 +204,8 @@ class Search extends PureComponent {
} else if (search.get('type') === 'hashtag') { } else if (search.get('type') === 'hashtag') {
router.history.push(`/tags/${search.get('q')}`); router.history.push(`/tags/${search.get('q')}`);
} }
this._unfocus();
}; };
handleForgetRecentSearchClick = search => { handleForgetRecentSearchClick = search => {
@ -208,6 +218,18 @@ class Search extends PureComponent {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} }
_insertText (text) {
const { value, onChange } = this.props;
if (value === '') {
onChange(text);
} else if (value[value.length - 1] === ' ') {
onChange(`${value}${text}`);
} else {
onChange(`${value} ${text}`);
}
}
_submit (type) { _submit (type) {
const { onSubmit, openInRoute } = this.props; const { onSubmit, openInRoute } = this.props;
const { router } = this.context; const { router } = this.context;
@ -217,6 +239,8 @@ class Search extends PureComponent {
if (openInRoute) { if (openInRoute) {
router.history.push('/search'); router.history.push('/search');
} }
this._unfocus();
} }
_getOptions () { _getOptions () {
@ -337,6 +361,20 @@ class Search extends PureComponent {
</div> </div>
</> </>
)} )}
{searchEnabled && (
<>
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
<div className='search__popout__menu'>
{this.defaultOptions.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
{label}
</button>
))}
</div>
</>
)}
</div> </div>
</div> </div>
); );

@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchFavourites } from 'flavours/glitch/actions/interactions'; import { debounce } from 'lodash';
import { fetchFavourites, expandFavourites } from 'flavours/glitch/actions/interactions';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
@ -23,7 +25,9 @@ const messages = defineMessages({
}); });
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
}); });
class Favourites extends ImmutablePureComponent { class Favourites extends ImmutablePureComponent {
@ -32,6 +36,8 @@ class Favourites extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -42,12 +48,6 @@ class Favourites extends ImmutablePureComponent {
} }
} }
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
}
}
handleHeaderClick = () => { handleHeaderClick = () => {
this.column.scrollTop(); this.column.scrollTop();
}; };
@ -60,8 +60,12 @@ class Favourites extends ImmutablePureComponent {
this.props.dispatch(fetchFavourites(this.props.params.statusId)); this.props.dispatch(fetchFavourites(this.props.params.statusId));
}; };
handleLoadMore = debounce(() => {
this.props.dispatch(expandFavourites(this.props.params.statusId));
}, 300, { leading: true });
render () { render () {
const { intl, accountIds, multiColumn } = this.props; const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -87,6 +91,9 @@ class Favourites extends ImmutablePureComponent {
/> />
<ScrollableList <ScrollableList
scrollKey='favourites' scrollKey='favourites'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

@ -0,0 +1,26 @@
import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => (
<div className='warning-banner'>
<div className='warning-banner__message'>
<h1>
<FormattedMessage
id='home.pending_critical_update.title'
defaultMessage='Critical security update available!'
/>
</h1>
<p>
<FormattedMessage
id='home.pending_critical_update.body'
defaultMessage='Please update your Mastodon server as soon as possible!'
/>{' '}
<a href='/admin/software_updates'>
<FormattedMessage
id='home.pending_critical_update.link'
defaultMessage='See updates'
/>
</a>
</p>
</div>
</div>
);

@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
import { me } from 'flavours/glitch/initial_state'; import { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container'; import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings'; import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt'; import { ExplorePrompt } from './components/explore_prompt';
const messages = defineMessages({ const messages = defineMessages({
@ -158,8 +159,9 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
const banners = [];
let announcementsButton, banner; let announcementsButton;
if (hasAnnouncements) { if (hasAnnouncements) {
announcementsButton = ( announcementsButton = (
@ -174,8 +176,12 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) { if (tooSlow) {
banner = <ExplorePrompt />; banners.push(<ExplorePrompt key='explore-prompt' />);
} }
return ( return (
@ -197,7 +203,7 @@ class HomeTimeline extends PureComponent {
{signedIn ? ( {signedIn ? (
<StatusListContainer <StatusListContainer
prepend={banner} prepend={banners}
alwaysPrepend alwaysPrepend
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`} scrollKey={`home_timeline-${columnId}`}

@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchReblogs } from 'flavours/glitch/actions/interactions'; import { debounce } from 'lodash';
import { fetchReblogs, expandReblogs } from 'flavours/glitch/actions/interactions';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
@ -16,17 +18,15 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
import AccountContainer from 'flavours/glitch/containers/account_container'; import AccountContainer from 'flavours/glitch/containers/account_container';
import Column from 'flavours/glitch/features/ui/components/column'; import Column from 'flavours/glitch/features/ui/components/column';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' }, heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' },
refresh: { id: 'refresh', defaultMessage: 'Refresh' }, refresh: { id: 'refresh', defaultMessage: 'Refresh' },
}); });
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
}); });
class Reblogs extends ImmutablePureComponent { class Reblogs extends ImmutablePureComponent {
@ -35,6 +35,8 @@ class Reblogs extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -45,12 +47,6 @@ class Reblogs extends ImmutablePureComponent {
} }
} }
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
}
}
handleHeaderClick = () => { handleHeaderClick = () => {
this.column.scrollTop(); this.column.scrollTop();
}; };
@ -63,8 +59,12 @@ class Reblogs extends ImmutablePureComponent {
this.props.dispatch(fetchReblogs(this.props.params.statusId)); this.props.dispatch(fetchReblogs(this.props.params.statusId));
}; };
handleLoadMore = debounce(() => {
this.props.dispatch(expandReblogs(this.props.params.statusId));
}, 300, { leading: true });
render () { render () {
const { intl, accountIds, multiColumn } = this.props; const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -91,6 +91,9 @@ class Reblogs extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='reblogs' scrollKey='reblogs'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

@ -29,6 +29,7 @@ const messages = defineMessages({
about: { id: 'navigation_bar.about', defaultMessage: 'About' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
}); });
@ -56,9 +57,13 @@ class NavigationPanel extends Component {
<div className='navigation-panel'> <div className='navigation-panel'>
{transientSingleColumn && ( {transientSingleColumn && (
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<a href={`/deck${location.pathname}`} className='button button--block'> <div class='switch-to-advanced'>
{intl.formatMessage(messages.advancedInterface)} {intl.formatMessage(messages.openedInClassicInterface)}
</a> {" "}
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
<hr /> <hr />
</div> </div>
)} )}

@ -100,6 +100,7 @@ export const hasMultiColumnPath = initialPath === '/'
* @typedef InitialState * @typedef InitialState
* @property {Record<string, Account>} accounts * @property {Record<string, Account>} accounts
* @property {InitialStateLanguage[]} languages * @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta
* @property {object} local_settings * @property {object} local_settings
* @property {number} max_toot_chars * @property {number} max_toot_chars
@ -160,6 +161,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const languages = initialState?.languages; export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');

@ -44,8 +44,18 @@ import {
FEATURED_TAGS_FETCH_FAIL, FEATURED_TAGS_FETCH_FAIL,
} from 'flavours/glitch/actions/featured_tags'; } from 'flavours/glitch/actions/featured_tags';
import { import {
REBLOGS_FETCH_REQUEST,
REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_SUCCESS,
REBLOGS_FETCH_FAIL,
REBLOGS_EXPAND_REQUEST,
REBLOGS_EXPAND_SUCCESS,
REBLOGS_EXPAND_FAIL,
FAVOURITES_FETCH_REQUEST,
FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS,
FAVOURITES_FETCH_FAIL,
FAVOURITES_EXPAND_REQUEST,
FAVOURITES_EXPAND_SUCCESS,
FAVOURITES_EXPAND_FAIL,
} from 'flavours/glitch/actions/interactions'; } from 'flavours/glitch/actions/interactions';
import { import {
MUTES_FETCH_REQUEST, MUTES_FETCH_REQUEST,
@ -133,9 +143,25 @@ export default function userLists(state = initialState, action) {
case FOLLOWING_EXPAND_FAIL: case FOLLOWING_EXPAND_FAIL:
return state.setIn(['following', action.id, 'isLoading'], false); return state.setIn(['following', action.id, 'isLoading'], false);
case REBLOGS_FETCH_SUCCESS: case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_EXPAND_SUCCESS:
return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_FETCH_REQUEST:
case REBLOGS_EXPAND_REQUEST:
return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
case REBLOGS_FETCH_FAIL:
case REBLOGS_EXPAND_FAIL:
return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
case FAVOURITES_FETCH_SUCCESS: case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_EXPAND_SUCCESS:
return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_FETCH_REQUEST:
case FAVOURITES_EXPAND_REQUEST:
return state.setIn(['favourited_by', action.id, 'isLoading'], true);
case FAVOURITES_FETCH_FAIL:
case FAVOURITES_EXPAND_FAIL:
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
case NOTIFICATIONS_UPDATE: case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS: case FOLLOW_REQUESTS_FETCH_SUCCESS:

@ -192,6 +192,8 @@
} }
.account-role, .account-role,
.information-badge,
.simple_form .overridden,
.simple_form .recommended, .simple_form .recommended,
.simple_form .not_recommended, .simple_form .not_recommended,
.simple_form .glitch_only { .simple_form .glitch_only {

@ -143,6 +143,11 @@ $content-width: 840px;
} }
} }
.warning a {
color: $gold-star;
font-weight: 700;
}
.simple-navigation-active-leaf a { .simple-navigation-active-leaf a {
color: $primary-text-color; color: $primary-text-color;
background-color: $ui-highlight-color; background-color: $ui-highlight-color;

@ -228,6 +228,22 @@ $ui-header-height: 55px;
top: -48px; top: -48px;
} }
.switch-to-advanced {
color: $classic-primary-color;
background-color: $classic-base-color;
padding: 15px;
border-radius: 4px;
margin-top: 4px;
margin-bottom: 12px;
font-size: 13px;
line-height: 18px;
.switch-to-advanced__toggle {
color: $ui-button-tertiary-color;
font-weight: bold;
}
}
.column-link { .column-link {
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
color: $primary-text-color; color: $primary-text-color;
@ -961,7 +977,8 @@ $ui-header-height: 55px;
} }
} }
.dismissable-banner { .dismissable-banner,
.warning-banner {
position: relative; position: relative;
margin: 10px; margin: 10px;
margin-bottom: 5px; margin-bottom: 5px;
@ -1039,6 +1056,21 @@ $ui-header-height: 55px;
} }
} }
.warning-banner {
border: 1px solid $warning-red;
background: rgba($warning-red, 0.15);
&__message {
h1 {
color: $warning-red;
}
a {
color: $primary-text-color;
}
}
}
.hashtag-header { .hashtag-header {
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
padding: 15px; padding: 15px;

@ -25,6 +25,12 @@
} }
&__menu { &__menu {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
&__message { &__message {
color: $dark-text-color; color: $dark-text-color;
padding: 0 10px; padding: 0 10px;

@ -120,6 +120,7 @@
.filter-form { .filter-form {
display: flex; display: flex;
flex-wrap: wrap;
} }
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {

@ -103,6 +103,7 @@ code {
} }
} }
.overridden,
.recommended, .recommended,
.not_recommended, .not_recommended,
.glitch_only { .glitch_only {
@ -1187,14 +1188,14 @@ code {
} }
li:first-child .label { li:first-child .label {
left: auto;
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: auto;
text-align: start; text-align: start;
transform: none; transform: none;
} }
li:last-child .label { li:last-child .label {
left: auto; inset-inline-start: auto;
inset-inline-end: 0; inset-inline-end: 0;
text-align: end; text-align: end;
transform: none; transform: none;

@ -12,6 +12,11 @@
border-top: 1px solid $ui-base-color; border-top: 1px solid $ui-base-color;
text-align: start; text-align: start;
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
&.critical {
font-weight: 700;
color: $gold-star;
}
} }
& > thead > tr > th { & > thead > tr > th {

@ -84,6 +84,7 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
}); });
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -246,7 +247,7 @@ export function submitCompose(routerHistory) {
} }
dispatch(showAlert({ dispatch(showAlert({
message: messages.published, message: statusId === null ? messages.published : messages.saved,
action: messages.open, action: messages.open,
dismissAfter: 10000, dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),

@ -1,11 +1,16 @@
import api from '../api'; import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL = 'REBLOG_FAIL'; export const REBLOG_FAIL = 'REBLOG_FAIL';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
@ -26,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS'; export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL = 'PIN_FAIL'; export const PIN_FAIL = 'PIN_FAIL';
@ -273,8 +282,10 @@ export function fetchReblogs(id) {
dispatch(fetchReblogsRequest(id)); dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(fetchReblogsSuccess(id, response.data)); dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchReblogsFail(id, error)); dispatch(fetchReblogsFail(id, error));
}); });
@ -288,17 +299,62 @@ export function fetchReblogsRequest(id) {
}; };
} }
export function fetchReblogsSuccess(id, accounts) { export function fetchReblogsSuccess(id, accounts, next) {
return { return {
type: REBLOGS_FETCH_SUCCESS, type: REBLOGS_FETCH_SUCCESS,
id, id,
accounts, accounts,
next,
}; };
} }
export function fetchReblogsFail(id, error) { export function fetchReblogsFail(id, error) {
return { return {
type: REBLOGS_FETCH_FAIL, type: REBLOGS_FETCH_FAIL,
id,
error,
};
}
export function expandReblogs(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandReblogsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandReblogsFail(id, error)));
};
}
export function expandReblogsRequest(id) {
return {
type: REBLOGS_EXPAND_REQUEST,
id,
};
}
export function expandReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandReblogsFail(id, error) {
return {
type: REBLOGS_EXPAND_FAIL,
id,
error, error,
}; };
} }
@ -308,8 +364,10 @@ export function fetchFavourites(id) {
dispatch(fetchFavouritesRequest(id)); dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(fetchFavouritesSuccess(id, response.data)); dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchFavouritesFail(id, error)); dispatch(fetchFavouritesFail(id, error));
}); });
@ -323,17 +381,62 @@ export function fetchFavouritesRequest(id) {
}; };
} }
export function fetchFavouritesSuccess(id, accounts) { export function fetchFavouritesSuccess(id, accounts, next) {
return { return {
type: FAVOURITES_FETCH_SUCCESS, type: FAVOURITES_FETCH_SUCCESS,
id, id,
accounts, accounts,
next,
}; };
} }
export function fetchFavouritesFail(id, error) { export function fetchFavouritesFail(id, error) {
return { return {
type: FAVOURITES_FETCH_FAIL, type: FAVOURITES_FETCH_FAIL,
id,
error,
};
}
export function expandFavourites(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFavouritesRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandFavouritesFail(id, error)));
};
}
export function expandFavouritesRequest(id) {
return {
type: FAVOURITES_EXPAND_REQUEST,
id,
};
}
export function expandFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandFavouritesFail(id, error) {
return {
type: FAVOURITES_EXPAND_FAIL,
id,
error, error,
}; };
} }

@ -10,8 +10,8 @@ import { groupBy, minBy } from 'lodash';
import { getStatusContent } from './status_content'; import { getStatusContent } from './status_content';
// About two lines on desktop // Fit on a single line on desktop
const VISIBLE_HASHTAGS = 7; const VISIBLE_HASHTAGS = 3;
// Those types are not correct, they need to be replaced once this part of the state is typed // Those types are not correct, they need to be replaced once this part of the state is typed
export type TagLike = Record<{ name: string }>; export type TagLike = Record<{ name: string }>;
@ -210,7 +210,7 @@ const HashtagBar: React.FC<{
const revealedHashtags = expanded const revealedHashtags = expanded
? hashtags ? hashtags
: hashtags.slice(0, VISIBLE_HASHTAGS - 1); : hashtags.slice(0, VISIBLE_HASHTAGS);
return ( return (
<div className='hashtag-bar'> <div className='hashtag-bar'>

@ -550,7 +550,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
@ -45,6 +45,16 @@ class Search extends PureComponent {
options: [], options: [],
}; };
defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
];
setRef = c => { setRef = c => {
this.searchForm = c; this.searchForm = c;
}; };
@ -70,7 +80,7 @@ class Search extends PureComponent {
handleKeyDown = (e) => { handleKeyDown = (e) => {
const { selectedOption } = this.state; const { selectedOption } = this.state;
const options = this._getOptions(); const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
switch(e.key) { switch(e.key) {
case 'Escape': case 'Escape':
@ -100,11 +110,9 @@ class Search extends PureComponent {
if (selectedOption === -1) { if (selectedOption === -1) {
this._submit(); this._submit();
} else if (options.length > 0) { } else if (options.length > 0) {
options[selectedOption].action(); options[selectedOption].action(e);
} }
this._unfocus();
break; break;
case 'Delete': case 'Delete':
if (selectedOption > -1 && options.length > 0) { if (selectedOption > -1 && options.length > 0) {
@ -147,6 +155,7 @@ class Search extends PureComponent {
router.history.push(`/tags/${query}`); router.history.push(`/tags/${query}`);
onClickSearchResult(query, 'hashtag'); onClickSearchResult(query, 'hashtag');
this._unfocus();
}; };
handleAccountClick = () => { handleAccountClick = () => {
@ -157,6 +166,7 @@ class Search extends PureComponent {
router.history.push(`/@${query}`); router.history.push(`/@${query}`);
onClickSearchResult(query, 'account'); onClickSearchResult(query, 'account');
this._unfocus();
}; };
handleURLClick = () => { handleURLClick = () => {
@ -164,6 +174,7 @@ class Search extends PureComponent {
const { value, onOpenURL } = this.props; const { value, onOpenURL } = this.props;
onOpenURL(value, router.history); onOpenURL(value, router.history);
this._unfocus();
}; };
handleStatusSearch = () => { handleStatusSearch = () => {
@ -182,6 +193,8 @@ class Search extends PureComponent {
} else if (search.get('type') === 'hashtag') { } else if (search.get('type') === 'hashtag') {
router.history.push(`/tags/${search.get('q')}`); router.history.push(`/tags/${search.get('q')}`);
} }
this._unfocus();
}; };
handleForgetRecentSearchClick = search => { handleForgetRecentSearchClick = search => {
@ -194,6 +207,18 @@ class Search extends PureComponent {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} }
_insertText (text) {
const { value, onChange } = this.props;
if (value === '') {
onChange(text);
} else if (value[value.length - 1] === ' ') {
onChange(`${value}${text}`);
} else {
onChange(`${value} ${text}`);
}
}
_submit (type) { _submit (type) {
const { onSubmit, openInRoute } = this.props; const { onSubmit, openInRoute } = this.props;
const { router } = this.context; const { router } = this.context;
@ -203,6 +228,8 @@ class Search extends PureComponent {
if (openInRoute) { if (openInRoute) {
router.history.push('/search'); router.history.push('/search');
} }
this._unfocus();
} }
_getOptions () { _getOptions () {
@ -325,6 +352,20 @@ class Search extends PureComponent {
</div> </div>
</> </>
)} )}
{searchEnabled && (
<>
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
<div className='search__popout__menu'>
{this.defaultOptions.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
{label}
</button>
))}
</div>
</>
)}
</div> </div>
</div> </div>
); );

@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchFavourites } from 'mastodon/actions/interactions'; import { debounce } from 'lodash';
import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
@ -21,7 +23,9 @@ const messages = defineMessages({
}); });
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
}); });
class Favourites extends ImmutablePureComponent { class Favourites extends ImmutablePureComponent {
@ -30,6 +34,8 @@ class Favourites extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -40,18 +46,16 @@ class Favourites extends ImmutablePureComponent {
} }
} }
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
}
}
handleRefresh = () => { handleRefresh = () => {
this.props.dispatch(fetchFavourites(this.props.params.statusId)); this.props.dispatch(fetchFavourites(this.props.params.statusId));
}; };
handleLoadMore = debounce(() => {
this.props.dispatch(expandFavourites(this.props.params.statusId));
}, 300, { leading: true });
render () { render () {
const { intl, accountIds, multiColumn } = this.props; const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -75,6 +79,9 @@ class Favourites extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='favourites' scrollKey='favourites'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

@ -0,0 +1,26 @@
import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => (
<div className='warning-banner'>
<div className='warning-banner__message'>
<h1>
<FormattedMessage
id='home.pending_critical_update.title'
defaultMessage='Critical security update available!'
/>
</h1>
<p>
<FormattedMessage
id='home.pending_critical_update.body'
defaultMessage='Please update your Mastodon server as soon as possible!'
/>{' '}
<a href='/admin/software_updates'>
<FormattedMessage
id='home.pending_critical_update.link'
defaultMessage='See updates'
/>
</a>
</p>
</div>
</div>
);

@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { me } from 'mastodon/initial_state'; import { me, criticalUpdatesPending } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container'; import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings'; import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt'; import { ExplorePrompt } from './components/explore_prompt';
const messages = defineMessages({ const messages = defineMessages({
@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
const banners = [];
let announcementsButton, banner; let announcementsButton;
if (hasAnnouncements) { if (hasAnnouncements) {
announcementsButton = ( announcementsButton = (
@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) { if (tooSlow) {
banner = <ExplorePrompt />; banners.push(<ExplorePrompt key='explore-prompt' />);
} }
return ( return (
@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent {
{signedIn ? ( {signedIn ? (
<StatusListContainer <StatusListContainer
prepend={banner} prepend={banners}
alwaysPrepend alwaysPrepend
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`} scrollKey={`home_timeline-${columnId}`}

@ -8,9 +8,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { fetchReblogs } from '../../actions/interactions'; import { fetchReblogs, expandReblogs } from '../../actions/interactions';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import { LoadingIndicator } from '../../components/loading_indicator'; import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
@ -22,7 +24,9 @@ const messages = defineMessages({
}); });
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
}); });
class Reblogs extends ImmutablePureComponent { class Reblogs extends ImmutablePureComponent {
@ -31,6 +35,8 @@ class Reblogs extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -39,20 +45,18 @@ class Reblogs extends ImmutablePureComponent {
if (!this.props.accountIds) { if (!this.props.accountIds) {
this.props.dispatch(fetchReblogs(this.props.params.statusId)); this.props.dispatch(fetchReblogs(this.props.params.statusId));
} }
} };
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
}
}
handleRefresh = () => { handleRefresh = () => {
this.props.dispatch(fetchReblogs(this.props.params.statusId)); this.props.dispatch(fetchReblogs(this.props.params.statusId));
}; };
handleLoadMore = debounce(() => {
this.props.dispatch(expandReblogs(this.props.params.statusId));
}, 300, { leading: true });
render () { render () {
const { intl, accountIds, multiColumn } = this.props; const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -76,6 +80,9 @@ class Reblogs extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='reblogs' scrollKey='reblogs'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

@ -31,6 +31,7 @@ const messages = defineMessages({
about: { id: 'navigation_bar.about', defaultMessage: 'About' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
}); });
class NavigationPanel extends Component { class NavigationPanel extends Component {
@ -57,12 +58,17 @@ class NavigationPanel extends Component {
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link> <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
{transientSingleColumn && ( {transientSingleColumn ? (
<a href={`/deck${location.pathname}`} className='button button--block'> <div class='switch-to-advanced'>
{intl.formatMessage(messages.advancedInterface)} {intl.formatMessage(messages.openedInClassicInterface)}
</a> {" "}
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
) : (
<hr />
)} )}
<hr />
</div> </div>
{signedIn && ( {signedIn && (

@ -87,6 +87,7 @@
* @typedef InitialState * @typedef InitialState
* @property {Record<string, Account>} accounts * @property {Record<string, Account>} accounts
* @property {InitialStateLanguage[]} languages * @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta
* @property {number} max_toot_chars * @property {number} max_toot_chars
*/ */
@ -141,6 +142,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const languages = initialState?.languages; export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
// @ts-expect-error // @ts-expect-error
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');

@ -137,6 +137,7 @@
"compose.language.search": "Search languages...", "compose.language.search": "Search languages...",
"compose.published.body": "Post published.", "compose.published.body": "Post published.",
"compose.published.open": "Open", "compose.published.open": "Open",
"compose.saved.body": "Post saved.",
"compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
@ -309,6 +310,9 @@
"home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:", "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
"home.explore_prompt.title": "This is your home base within Mastodon.", "home.explore_prompt.title": "This is your home base within Mastodon.",
"home.hide_announcements": "Hide announcements", "home.hide_announcements": "Hide announcements",
"home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
"home.pending_critical_update.link": "See updates",
"home.pending_critical_update.title": "Critical security update available!",
"home.show_announcements": "Show announcements", "home.show_announcements": "Show announcements",
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.", "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.", "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
@ -410,6 +414,7 @@
"navigation_bar.lists": "Lists", "navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users", "navigation_bar.mutes": "Muted users",
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
"navigation_bar.personal": "Personal", "navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned posts", "navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
@ -585,8 +590,12 @@
"search.quick_action.open_url": "Open URL in Mastodon", "search.quick_action.open_url": "Open URL in Mastodon",
"search.quick_action.status_search": "Posts matching {x}", "search.quick_action.status_search": "Posts matching {x}",
"search.search_or_paste": "Search or paste URL", "search.search_or_paste": "Search or paste URL",
"search_popout.language_code": "ISO language code",
"search_popout.options": "Search options",
"search_popout.quick_actions": "Quick actions", "search_popout.quick_actions": "Quick actions",
"search_popout.recent": "Recent searches", "search_popout.recent": "Recent searches",
"search_popout.specific_date": "specific date",
"search_popout.user": "user",
"search_results.accounts": "Profiles", "search_results.accounts": "Profiles",
"search_results.all": "All", "search_results.all": "All",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

@ -409,6 +409,7 @@
"navigation_bar.lists": "Listes", "navigation_bar.lists": "Listes",
"navigation_bar.logout": "Déconnexion", "navigation_bar.logout": "Déconnexion",
"navigation_bar.mutes": "Comptes masqués", "navigation_bar.mutes": "Comptes masqués",
"navigation_bar.opened_in_classic_interface": "Les messages, les comptes et les pages spécifiques sont ouvertes dans linterface classique.",
"navigation_bar.personal": "Personnel", "navigation_bar.personal": "Personnel",
"navigation_bar.pins": "Messages épinglés", "navigation_bar.pins": "Messages épinglés",
"navigation_bar.preferences": "Préférences", "navigation_bar.preferences": "Préférences",

@ -45,8 +45,18 @@ import {
BLOCKS_EXPAND_FAIL, BLOCKS_EXPAND_FAIL,
} from '../actions/blocks'; } from '../actions/blocks';
import { import {
REBLOGS_FETCH_REQUEST,
REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_SUCCESS,
REBLOGS_FETCH_FAIL,
REBLOGS_EXPAND_REQUEST,
REBLOGS_EXPAND_SUCCESS,
REBLOGS_EXPAND_FAIL,
FAVOURITES_FETCH_REQUEST,
FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS,
FAVOURITES_FETCH_FAIL,
FAVOURITES_EXPAND_REQUEST,
FAVOURITES_EXPAND_SUCCESS,
FAVOURITES_EXPAND_FAIL,
} from '../actions/interactions'; } from '../actions/interactions';
import { import {
MUTES_FETCH_REQUEST, MUTES_FETCH_REQUEST,
@ -134,9 +144,25 @@ export default function userLists(state = initialState, action) {
case FOLLOWING_EXPAND_FAIL: case FOLLOWING_EXPAND_FAIL:
return state.setIn(['following', action.id, 'isLoading'], false); return state.setIn(['following', action.id, 'isLoading'], false);
case REBLOGS_FETCH_SUCCESS: case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_EXPAND_SUCCESS:
return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_FETCH_REQUEST:
case REBLOGS_EXPAND_REQUEST:
return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
case REBLOGS_FETCH_FAIL:
case REBLOGS_EXPAND_FAIL:
return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
case FAVOURITES_FETCH_SUCCESS: case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_EXPAND_SUCCESS:
return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_FETCH_REQUEST:
case FAVOURITES_EXPAND_REQUEST:
return state.setIn(['favourited_by', action.id, 'isLoading'], true);
case FAVOURITES_FETCH_FAIL:
case FAVOURITES_EXPAND_FAIL:
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
case NOTIFICATIONS_UPDATE: case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS: case FOLLOW_REQUESTS_FETCH_SUCCESS:

@ -1 +1 @@
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom';

@ -7,7 +7,6 @@ import { defineMessages } from 'react-intl';
import { delegate } from '@rails/ujs'; import { delegate } from '@rails/ujs';
import axios from 'axios'; import axios from 'axios';
import { createBrowserHistory } from 'history';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { start } from '../mastodon/common'; import { start } from '../mastodon/common';
@ -31,23 +30,6 @@ const messages = defineMessages({
function loaded() { function loaded() {
const { messages: localeData } = getLocale(); const { messages: localeData } = getLocale();
const scrollToDetailedStatus = () => {
const history = createBrowserHistory();
const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
const location = history.location;
if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
detailedStatuses[0].scrollIntoView();
history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
}
};
const getEmojiAnimationHandler = (swapTo) => {
return ({ target }) => {
target.src = target.getAttribute(swapTo);
};
};
const locale = document.documentElement.lang; const locale = document.documentElement.lang;
const dateTimeFormat = new Intl.DateTimeFormat(locale, { const dateTimeFormat = new Intl.DateTimeFormat(locale, {
@ -141,27 +123,21 @@ function loaded() {
const root = createRoot(content); const root = createRoot(content);
root.render(<MediaContainer locale={locale} components={reactComponents} />); root.render(<MediaContainer locale={locale} components={reactComponents} />);
document.body.appendChild(content); document.body.appendChild(content);
scrollToDetailedStatus();
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
scrollToDetailedStatus();
}); });
} else {
scrollToDetailedStatus();
} }
delegate(document, '#user_account_attributes_username', 'input', throttle(() => { delegate(document, '#user_account_attributes_username', 'input', throttle(({ target }) => {
const username = document.getElementById('user_account_attributes_username'); if (target.value && target.value.length > 0) {
axios.get('/api/v1/accounts/lookup', { params: { acct: target.value } }).then(() => {
if (username.value && username.value.length > 0) { target.setCustomValidity(formatMessage(messages.usernameTaken));
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
username.setCustomValidity(formatMessage(messages.usernameTaken));
}).catch(() => { }).catch(() => {
username.setCustomValidity(''); target.setCustomValidity('');
}); });
} else { } else {
username.setCustomValidity(''); target.setCustomValidity('');
} }
}, 500, { leading: false, trailing: true })); }, 500, { leading: false, trailing: true }));
@ -179,9 +155,6 @@ function loaded() {
} }
}); });
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
delegate(document, '.status__content__spoiler-link', 'click', function() { delegate(document, '.status__content__spoiler-link', 'click', function() {
const statusEl = this.parentNode.parentNode; const statusEl = this.parentNode.parentNode;
@ -230,6 +203,9 @@ delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
} }
}); });
delegate(document, '.custom-emoji', 'mouseover', ({ target }) => target.src = target.getAttribute('data-original'));
delegate(document, '.custom-emoji', 'mouseout', ({ target }) => target.src = target.getAttribute('data-static'));
// Empty the honeypot fields in JS in case something like an extension // Empty the honeypot fields in JS in case something like an extension
// automatically filled them. // automatically filled them.
delegate(document, '#registration_new_user,#new_user', 'submit', () => { delegate(document, '#registration_new_user,#new_user', 'submit', () => {

@ -188,6 +188,7 @@
} }
.information-badge, .information-badge,
.simple_form .overridden,
.simple_form .recommended, .simple_form .recommended,
.simple_form .not_recommended { .simple_form .not_recommended {
display: inline-block; display: inline-block;
@ -204,6 +205,7 @@
} }
.information-badge, .information-badge,
.simple_form .overridden,
.simple_form .recommended, .simple_form .recommended,
.simple_form .not_recommended { .simple_form .not_recommended {
background-color: rgba($ui-secondary-color, 0.1); background-color: rgba($ui-secondary-color, 0.1);

@ -143,6 +143,11 @@ $content-width: 840px;
} }
} }
.warning a {
color: $gold-star;
font-weight: 700;
}
.simple-navigation-active-leaf a { .simple-navigation-active-leaf a {
color: $primary-text-color; color: $primary-text-color;
background-color: $ui-highlight-color; background-color: $ui-highlight-color;

@ -2381,6 +2381,7 @@ $ui-header-height: 55px;
.filter-form { .filter-form {
display: flex; display: flex;
flex-wrap: wrap;
} }
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {
@ -3270,6 +3271,22 @@ $ui-header-height: 55px;
border-color: $ui-highlight-color; border-color: $ui-highlight-color;
} }
.switch-to-advanced {
color: $classic-primary-color;
background-color: $classic-base-color;
padding: 15px;
border-radius: 4px;
margin-top: 4px;
margin-bottom: 12px;
font-size: 13px;
line-height: 18px;
.switch-to-advanced__toggle {
color: $ui-button-tertiary-color;
font-weight: bold;
}
}
.column-link { .column-link {
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
color: $primary-text-color; color: $primary-text-color;
@ -4991,6 +5008,12 @@ a.status-card {
} }
&__menu { &__menu {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
&__message { &__message {
color: $dark-text-color; color: $dark-text-color;
padding: 0 10px; padding: 0 10px;
@ -8837,7 +8860,8 @@ noscript {
} }
} }
.dismissable-banner { .dismissable-banner,
.warning-banner {
position: relative; position: relative;
margin: 10px; margin: 10px;
margin-bottom: 5px; margin-bottom: 5px;
@ -8915,6 +8939,21 @@ noscript {
} }
} }
.warning-banner {
border: 1px solid $warning-red;
background: rgba($warning-red, 0.15);
&__message {
h1 {
color: $warning-red;
}
a {
color: $primary-text-color;
}
}
}
.image { .image {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -9302,19 +9341,24 @@ noscript {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 14px; font-size: 14px;
line-height: 18px;
gap: 4px; gap: 4px;
color: $darker-text-color;
a { a {
display: inline-flex; display: inline-flex;
color: $dark-text-color; color: inherit;
text-decoration: none; text-decoration: none;
&:hover { &:hover span {
text-decoration: none; text-decoration: underline;
span {
text-decoration: underline;
}
} }
} }
.link-button {
color: inherit;
font-size: inherit;
line-height: inherit;
padding: 0;
}
} }

@ -103,6 +103,7 @@ code {
} }
} }
.overridden,
.recommended, .recommended,
.not_recommended { .not_recommended {
position: absolute; position: absolute;
@ -1185,14 +1186,14 @@ code {
} }
li:first-child .label { li:first-child .label {
left: auto;
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: auto;
text-align: start; text-align: start;
transform: none; transform: none;
} }
li:last-child .label { li:last-child .label {
left: auto; inset-inline-start: auto;
inset-inline-end: 0; inset-inline-end: 0;
text-align: end; text-align: end;
transform: none; transform: none;

@ -12,6 +12,11 @@
border-top: 1px solid $ui-base-color; border-top: 1px solid $ui-base-color;
text-align: start; text-align: start;
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
&.critical {
font-weight: 700;
color: $gold-star;
}
} }
& > thead > tr > th { & > thead > tr > th {

@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
protected protected
def perform_query def perform_query
[mastodon_version, ruby_version, postgresql_version, redis_version] [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact
end end
def mastodon_version def mastodon_version
@ -57,6 +57,22 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
} }
end end
def elasticsearch_version
return unless Chewy.enabled?
client_info = Chewy.client.info
version = client_info.dig('version', 'number')
{
key: 'elasticsearch',
human_key: client_info.dig('version', 'distribution') == 'opensearch' ? 'OpenSearch' : 'Elasticsearch',
value: version,
human_value: version,
}
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
nil
end
def redis_info def redis_info
@redis_info ||= if redis.is_a?(Redis::Namespace) @redis_info ||= if redis.is_a?(Redis::Namespace)
redis.redis.info redis.redis.info

@ -2,6 +2,7 @@
class Admin::SystemCheck class Admin::SystemCheck
ACTIVE_CHECKS = [ ACTIVE_CHECKS = [
Admin::SystemCheck::SoftwareVersionCheck,
Admin::SystemCheck::MediaPrivacyCheck, Admin::SystemCheck::MediaPrivacyCheck,
Admin::SystemCheck::DatabaseSchemaCheck, Admin::SystemCheck::DatabaseSchemaCheck,
Admin::SystemCheck::SidekiqProcessCheck, Admin::SystemCheck::SidekiqProcessCheck,

@ -6,6 +6,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
AccountsIndex, AccountsIndex,
TagsIndex, TagsIndex,
StatusesIndex, StatusesIndex,
PublicStatusesIndex,
].freeze ].freeze
def skip? def skip?
@ -85,7 +86,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
def mismatched_indexes def mismatched_indexes
@mismatched_indexes ||= INDEXES.filter_map do |klass| @mismatched_indexes ||= INDEXES.filter_map do |klass|
klass.index_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash klass.base_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
end end
end end

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck
include RoutingHelper
def skip?
!current_user.can?(:view_devops) || !SoftwareUpdate.check_enabled?
end
def pass?
software_updates.empty?
end
def message
if software_updates.any?(&:urgent?)
Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true)
else
Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path)
end
end
private
def software_updates
@software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? }
end
end

@ -4,10 +4,10 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
def import! def import!
scope.includes(:account_stat).find_in_batches(batch_size: @batch_size) do |tmp| scope.includes(:account_stat).find_in_batches(batch_size: @batch_size) do |tmp|
in_work_unit(tmp) do |accounts| in_work_unit(tmp) do |accounts|
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body bulk = build_bulk_body(accounts)
indexed = bulk.count { |entry| entry[:index] } indexed = bulk.size
deleted = bulk.count { |entry| entry[:delete] } deleted = 0
Chewy::Index::Import::BulkRequest.new(index).perform(bulk) Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

@ -68,6 +68,14 @@ class Importer::BaseImporter
protected protected
def build_bulk_body(to_import)
# Specialize `Chewy::Index::Import::BulkBuilder#bulk_body` to avoid a few
# inefficiencies, as none of our fields or join fields and we do not need
# `BulkBuilder`'s versatility.
crutches = Chewy::Index::Crutch::Crutches.new index, to_import
to_import.map { |object| { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } } }
end
def in_work_unit(...) def in_work_unit(...)
work_unit = Concurrent::Promises.future_on(@executor, ...) work_unit = Concurrent::Promises.future_on(@executor, ...)

@ -4,10 +4,10 @@ class Importer::InstancesIndexImporter < Importer::BaseImporter
def import! def import!
index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp| index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
in_work_unit(tmp) do |instances| in_work_unit(tmp) do |instances|
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: instances).bulk_body bulk = build_bulk_body(instances)
indexed = bulk.count { |entry| entry[:index] } indexed = bulk.size
deleted = bulk.count { |entry| entry[:delete] } deleted = 0
Chewy::Index::Import::BulkRequest.new(index).perform(bulk) Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

@ -0,0 +1,32 @@
# frozen_string_literal: true
class Importer::PublicStatusesIndexImporter < Importer::BaseImporter
def import!
scope.select(:id).find_in_batches(batch_size: @batch_size) do |batch|
in_work_unit(batch.pluck(:id)) do |status_ids|
bulk = ActiveRecord::Base.connection_pool.with_connection do
build_bulk_body(index.adapter.default_scope.where(id: status_ids))
end
indexed = bulk.size
deleted = 0
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
[indexed, deleted]
end
end
wait!
end
private
def index
PublicStatusesIndex
end
def scope
Status.indexable
end
end

@ -13,32 +13,25 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter
scope.find_in_batches(batch_size: @batch_size) do |tmp| scope.find_in_batches(batch_size: @batch_size) do |tmp|
in_work_unit(tmp.map(&:status_id)) do |status_ids| in_work_unit(tmp.map(&:status_id)) do |status_ids|
bulk = ActiveRecord::Base.connection_pool.with_connection do
Chewy::Index::Import::BulkBuilder.new(index, to_index: Status.includes(:media_attachments, :preloadable_poll).where(id: status_ids)).bulk_body
end
indexed = 0
deleted = 0 deleted = 0
# We can't use the delete_if proc to do the filtering because delete_if bulk = ActiveRecord::Base.connection_pool.with_connection do
# is called before rendering the data and we need to filter based to_index = index.adapter.default_scope.where(id: status_ids)
# on the results of the filter, so this filtering happens here instead crutches = Chewy::Index::Crutch::Crutches.new index, to_index
bulk.map! do |entry| to_index.map do |object|
new_entry = if entry[:index] && entry.dig(:index, :data, 'searchable_by').blank? # This is unlikely to happen, but the post may have been
{ delete: entry[:index].except(:data) } # un-interacted with since it was queued for indexing
else if object.searchable_by.empty?
entry deleted += 1
end { delete: { _id: object.id } }
else
if new_entry[:index] { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } }
indexed += 1 end
else
deleted += 1
end end
new_entry
end end
indexed = bulk.size - deleted
Chewy::Index::Import::BulkRequest.new(index).perform(bulk) Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
[indexed, deleted] [indexed, deleted]

@ -4,10 +4,10 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
def import! def import!
index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp| index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
in_work_unit(tmp) do |tags| in_work_unit(tmp) do |tags|
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body bulk = build_bulk_body(tags)
indexed = bulk.count { |entry| entry[:index] } indexed = bulk.size
deleted = bulk.count { |entry| entry[:delete] } deleted = 0
Chewy::Index::Import::BulkRequest.new(index).perform(bulk) Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class PlainTextFormatter class PlainTextFormatter
include ActionView::Helpers::TextHelper
NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+} NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+}
attr_reader :text, :local attr_reader :text, :local
@ -18,7 +16,10 @@ class PlainTextFormatter
if local? if local?
text text
else else
html_entities.decode(strip_tags(insert_newlines)).chomp node = Nokogiri::HTML.fragment(insert_newlines)
# Elements that are entirely removed with our Sanitize config
node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
node.text.chomp
end end
end end
@ -27,8 +28,4 @@ class PlainTextFormatter
def insert_newlines def insert_newlines
text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" } text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
end end
def html_entities
HTMLEntities.new
end
end end

@ -6,10 +6,10 @@ class SearchQueryParser < Parslet::Parser
rule(:colon) { str(':') } rule(:colon) { str(':') }
rule(:space) { match('\s').repeat(1) } rule(:space) { match('\s').repeat(1) }
rule(:operator) { (str('+') | str('-')).as(:operator) } rule(:operator) { (str('+') | str('-')).as(:operator) }
rule(:prefix) { (term >> colon).as(:prefix) } rule(:prefix) { term >> colon }
rule(:shortcode) { (colon >> term >> colon.maybe).as(:shortcode) } rule(:shortcode) { (colon >> term >> colon.maybe).as(:shortcode) }
rule(:phrase) { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) } rule(:phrase) { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) }
rule(:clause) { (prefix.maybe >> operator.maybe >> (phrase | term | shortcode)).as(:clause) } rule(:clause) { (operator.maybe >> prefix.maybe.as(:prefix) >> (phrase | term | shortcode)).as(:clause) | prefix.as(:clause) | quote.as(:junk) }
rule(:query) { (clause >> space.maybe).repeat.as(:query) } rule(:query) { (clause >> space.maybe).repeat.as(:query) }
root(:query) root(:query)
end end

@ -1,58 +1,42 @@
# frozen_string_literal: true # frozen_string_literal: true
class SearchQueryTransformer < Parslet::Transform class SearchQueryTransformer < Parslet::Transform
SUPPORTED_PREFIXES = %w(
has
is
language
from
before
after
during
).freeze
class Query class Query
attr_reader :should_clauses, :must_not_clauses, :must_clauses, :filter_clauses attr_reader :must_not_clauses, :must_clauses, :filter_clauses
def initialize(clauses) def initialize(clauses)
grouped = clauses.chunk(&:operator).to_h grouped = clauses.compact.chunk(&:operator).to_h
@should_clauses = grouped.fetch(:should, [])
@must_not_clauses = grouped.fetch(:must_not, []) @must_not_clauses = grouped.fetch(:must_not, [])
@must_clauses = grouped.fetch(:must, []) @must_clauses = grouped.fetch(:must, [])
@filter_clauses = grouped.fetch(:filter, []) @filter_clauses = grouped.fetch(:filter, [])
end end
def apply(search) def apply(search)
should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) } must_clauses.each { |clause| search = search.query.must(clause.to_query) }
must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) } must_not_clauses.each { |clause| search = search.query.must_not(clause.to_query) }
must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) } filter_clauses.each { |clause| search = search.filter(**clause.to_query) }
filter_clauses.each { |clause| search = search.filter(**clause_to_filter(clause)) }
search.query.minimum_should_match(1) search.query.minimum_should_match(1)
end end
private
def clause_to_query(clause)
case clause
when TermClause
{ multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } }
when PhraseClause
{ match_phrase: { text: { query: clause.phrase } } }
else
raise "Unexpected clause type: #{clause}"
end
end
def clause_to_filter(clause)
case clause
when PrefixClause
{ term: { clause.filter => clause.term } }
else
raise "Unexpected clause type: #{clause}"
end
end
end end
class Operator class Operator
class << self class << self
def symbol(str) def symbol(str)
case str case str
when '+' when '+', nil
:must :must
when '-' when '-'
:must_not :must_not
when nil
:should
else else
raise "Unknown operator: #{str}" raise "Unknown operator: #{str}"
end end
@ -61,61 +45,133 @@ class SearchQueryTransformer < Parslet::Transform
end end
class TermClause class TermClause
attr_reader :prefix, :operator, :term attr_reader :operator, :term
def initialize(prefix, operator, term) def initialize(operator, term)
@prefix = prefix
@operator = Operator.symbol(operator) @operator = Operator.symbol(operator)
@term = term @term = term
end end
def to_query
{ multi_match: { type: 'most_fields', query: @term, fields: ['text', 'text.stemmed'], operator: 'and' } }
end
end end
class PhraseClause class PhraseClause
attr_reader :prefix, :operator, :phrase attr_reader :operator, :phrase
def initialize(prefix, operator, phrase) def initialize(operator, phrase)
@prefix = prefix
@operator = Operator.symbol(operator) @operator = Operator.symbol(operator)
@phrase = phrase @phrase = phrase
end end
def to_query
{ match_phrase: { text: { query: @phrase } } }
end
end end
class PrefixClause class PrefixClause
attr_reader :filter, :operator, :term attr_reader :operator, :prefix, :term
def initialize(prefix, term) def initialize(prefix, operator, term, options = {})
@prefix = prefix
@negated = operator == '-'
@options = options
@operator = :filter @operator = :filter
case prefix case prefix
when 'has', 'is'
@filter = :properties
@type = :term
@term = term
when 'language'
@filter = :language
@type = :term
@term = language_code_from_term(term)
when 'from' when 'from'
@filter = :account_id @filter = :account_id
@type = :term
@term = account_id_from_term(term)
when 'before'
@filter = :created_at
@type = :range
@term = { lt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
when 'after'
@filter = :created_at
@type = :range
@term = { gt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
when 'during'
@filter = :created_at
@type = :range
@term = { gte: term, lte: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
else
raise "Unknown prefix: #{prefix}"
end
end
username, domain = term.gsub(/\A@/, '').split('@') def to_query
domain = nil if TagManager.instance.local_domain?(domain) if @negated
account = Account.find_remote!(username, domain) { bool: { must_not: { @type => { @filter => @term } } } }
@term = account.id
else else
raise Mastodon::SyntaxError { @type => { @filter => @term } }
end end
end end
private
def account_id_from_term(term)
return @options[:current_account]&.id || -1 if term == 'me'
username, domain = term.gsub(/\A@/, '').split('@')
domain = nil if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain)
# If the account is not found, we want to return empty results, so return
# an ID that does not exist
account&.id || -1
end
def language_code_from_term(term)
language_code = term
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
language_code = term.downcase
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
language_code = term.split(/[_-]/).first.downcase
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
term
end
end end
rule(clause: subtree(:clause)) do rule(clause: subtree(:clause)) do
prefix = clause[:prefix][:term].to_s if clause[:prefix] prefix = clause[:prefix][:term].to_s if clause[:prefix]
operator = clause[:operator]&.to_s operator = clause[:operator]&.to_s
if clause[:prefix] if clause[:prefix] && SUPPORTED_PREFIXES.include?(prefix)
PrefixClause.new(prefix, clause[:term].to_s) PrefixClause.new(prefix, operator, clause[:term].to_s, current_account: current_account)
elsif clause[:prefix]
TermClause.new(operator, "#{prefix} #{clause[:term]}")
elsif clause[:term] elsif clause[:term]
TermClause.new(prefix, operator, clause[:term].to_s) TermClause.new(operator, clause[:term].to_s)
elsif clause[:shortcode] elsif clause[:shortcode]
TermClause.new(prefix, operator, ":#{clause[:term]}:") TermClause.new(operator, ":#{clause[:term]}:")
elsif clause[:phrase] elsif clause[:phrase]
PhraseClause.new(prefix, operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s) PhraseClause.new(operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
else else
raise "Unexpected clause type: #{clause}" raise "Unexpected clause type: #{clause}"
end end
end end
rule(query: sequence(:clauses)) { Query.new(clauses) } rule(junk: subtree(:junk)) do
nil
end
rule(query: sequence(:clauses)) do
Query.new(clauses)
end
end end

@ -20,7 +20,10 @@ class Vacuum::StatusesVacuum
statuses.direct_visibility statuses.direct_visibility
.includes(mentions: :account) .includes(mentions: :account)
.find_each(&:unlink_from_conversations!) .find_each(&:unlink_from_conversations!)
remove_from_search_index(statuses.ids) if Chewy.enabled? if Chewy.enabled?
remove_from_index(statuses.ids, 'chewy:queue:StatusesIndex')
remove_from_index(statuses.ids, 'chewy:queue:PublicStatusesIndex')
end
# Foreign keys take care of most associated records for us. # Foreign keys take care of most associated records for us.
# Media attachments will be orphaned. # Media attachments will be orphaned.
@ -38,7 +41,7 @@ class Vacuum::StatusesVacuum
Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false) Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false)
end end
def remove_from_search_index(status_ids) def remove_from_index(status_ids, index)
with_redis { |redis| redis.sadd('chewy:queue:StatusesIndex', status_ids) } with_redis { |redis| redis.sadd(index, status_ids) }
end end
end end

@ -45,6 +45,22 @@ class AdminMailer < ApplicationMailer
end end
end end
def new_software_updates
locale_for_account(@me) do
mail subject: default_i18n_subject(instance: @instance)
end
end
def new_critical_software_updates
headers['Priority'] = 'urgent'
headers['X-Priority'] = '1'
headers['Importance'] = 'high'
locale_for_account(@me) do
mail subject: default_i18n_subject(instance: @instance)
end
end
private private
def process_params def process_params

@ -82,6 +82,7 @@ class Account < ApplicationRecord
include DomainMaterializable include DomainMaterializable
include AccountMerging include AccountMerging
include AccountSearch include AccountSearch
include AccountStatusesSearch
MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i

@ -0,0 +1,44 @@
# frozen_string_literal: true
module AccountStatusesSearch
extend ActiveSupport::Concern
included do
after_update_commit :enqueue_update_public_statuses_index, if: :saved_change_to_indexable?
after_destroy_commit :enqueue_remove_from_public_statuses_index, if: :indexable?
end
def enqueue_update_public_statuses_index
if indexable?
enqueue_add_to_public_statuses_index
else
enqueue_remove_from_public_statuses_index
end
end
def enqueue_add_to_public_statuses_index
return unless Chewy.enabled?
AddToPublicStatusesIndexWorker.perform_async(id)
end
def enqueue_remove_from_public_statuses_index
return unless Chewy.enabled?
RemoveFromPublicStatusesIndexWorker.perform_async(id)
end
def add_to_public_statuses_index!
return unless Chewy.enabled?
statuses.without_reblogs.where(visibility: :public).find_in_batches do |batch|
PublicStatusesIndex.import(batch)
end
end
def remove_from_public_statuses_index!
return unless Chewy.enabled?
PublicStatusesIndex.filter(term: { account_id: id }).delete_all
end
end

@ -0,0 +1,48 @@
# frozen_string_literal: true
module StatusSearchConcern
extend ActiveSupport::Concern
included do
scope :indexable, -> { without_reblogs.where(visibility: :public).joins(:account).where(account: { indexable: true }) }
end
def searchable_by
@searchable_by ||= begin
ids = []
ids << account_id if local?
ids += local_mentioned.pluck(:id)
ids += local_favorited.pluck(:id)
ids += local_reblogged.pluck(:id)
ids += local_bookmarked.pluck(:id)
ids += preloadable_poll.local_voters.pluck(:id) if preloadable_poll.present?
ids.uniq
end
end
def searchable_text
[
spoiler_text,
FormattingHelper.extract_status_plain_text(self),
preloadable_poll&.options&.join("\n\n"),
ordered_media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
end
def searchable_properties
[].tap do |properties|
properties << 'image' if ordered_media_attachments.any?(&:image?)
properties << 'video' if ordered_media_attachments.any?(&:video?)
properties << 'audio' if ordered_media_attachments.any?(&:audio?)
properties << 'media' if with_media?
properties << 'poll' if with_poll?
properties << 'link' if with_preview_card?
properties << 'embed' if preview_cards.any?(&:video?)
properties << 'sensitive' if sensitive?
properties << 'reply' if reply?
end
end
end

@ -3,6 +3,8 @@
class Form::AdminSettings class Form::AdminSettings
include ActiveModel::Model include ActiveModel::Model
include AuthorizedFetchHelper
KEYS = %i( KEYS = %i(
site_contact_username site_contact_username
site_contact_email site_contact_email
@ -42,6 +44,7 @@ class Form::AdminSettings
backups_retention_period backups_retention_period
status_page_url status_page_url
captcha_enabled captcha_enabled
authorized_fetch
).freeze ).freeze
INTEGER_KEYS = %i( INTEGER_KEYS = %i(
@ -66,6 +69,7 @@ class Form::AdminSettings
noindex noindex
require_invite_text require_invite_text
captcha_enabled captcha_enabled
authorized_fetch
).freeze ).freeze
UPLOAD_KEYS = %i( UPLOAD_KEYS = %i(
@ -77,6 +81,10 @@ class Form::AdminSettings
flavour_and_skin flavour_and_skin
).freeze ).freeze
OVERRIDEN_SETTINGS = {
authorized_fetch: :authorized_fetch_mode?,
}.freeze
attr_accessor(*KEYS) attr_accessor(*KEYS)
validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) } validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
@ -96,6 +104,8 @@ class Form::AdminSettings
stored_value = if UPLOAD_KEYS.include?(key) stored_value = if UPLOAD_KEYS.include?(key)
SiteUpload.where(var: key).first_or_initialize(var: key) SiteUpload.where(var: key).first_or_initialize(var: key)
elsif OVERRIDEN_SETTINGS.include?(key)
public_send(OVERRIDEN_SETTINGS[key])
else else
Setting.public_send(key) Setting.public_send(key)
end end

@ -44,6 +44,7 @@ class MediaAttachment < ApplicationRecord
MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
MAX_VIDEO_FRAME_RATE = 120 MAX_VIDEO_FRAME_RATE = 120
MAX_VIDEO_FRAMES = 36_000 # Approx. 5 minutes at 120 fps
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
@ -98,17 +99,15 @@ class MediaAttachment < ApplicationRecord
convert_options: { convert_options: {
output: { output: {
'loglevel' => 'fatal', 'loglevel' => 'fatal',
'movflags' => 'faststart', 'preset' => 'veryfast',
'pix_fmt' => 'yuv420p', 'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'', 'pix_fmt' => 'yuv420p', # Ensure color space for cross-browser compatibility
'vsync' => 'cfr', 'vf' => 'crop=floor(iw/2)*2:floor(ih/2)*2', # h264 requires width and height to be even. Crop instead of scale to avoid blurring
'c:v' => 'h264', 'c:v' => 'h264',
'maxrate' => '1300K', 'c:a' => 'aac',
'bufsize' => '1300K', 'b:a' => '192k',
'b:v' => '1300K',
'frames:v' => 60 * 60 * 3,
'crf' => 18,
'map_metadata' => '-1', 'map_metadata' => '-1',
'frames:v' => MAX_VIDEO_FRAMES,
}.freeze, }.freeze,
}.freeze, }.freeze,
}.freeze }.freeze
@ -135,7 +134,7 @@ class MediaAttachment < ApplicationRecord
convert_options: { convert_options: {
output: { output: {
'loglevel' => 'fatal', 'loglevel' => 'fatal',
:vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', :vf => 'scale=\'min(640\, iw):min(640\, ih)\':force_original_aspect_ratio=decrease',
}.freeze, }.freeze,
}.freeze, }.freeze,
format: 'png', format: 'png',

@ -28,6 +28,7 @@ class Poll < ApplicationRecord
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account
has_many :local_voters, -> { group('accounts.id').merge(Account.local) }, through: :votes, class_name: 'Account', source: :account
has_many :notifications, as: :activity, dependent: :destroy has_many :notifications, as: :activity, dependent: :destroy

@ -0,0 +1,40 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: software_updates
#
# id :bigint(8) not null, primary key
# version :string not null
# urgent :boolean default(FALSE), not null
# type :integer default("patch"), not null
# release_notes :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class SoftwareUpdate < ApplicationRecord
self.inheritance_column = nil
enum type: { patch: 0, minor: 1, major: 2 }, _suffix: :type
def gem_version
Gem::Version.new(version)
end
class << self
def check_enabled?
ENV['UPDATE_CHECK_URL'] != ''
end
def pending_to_a
return [] unless check_enabled?
all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version }
end
def urgent_pending?
pending_to_a.any?(&:urgent?)
end
end
end

@ -39,6 +39,7 @@ class Status < ApplicationRecord
include StatusSnapshotConcern include StatusSnapshotConcern
include RateLimitable include RateLimitable
include StatusSafeReblogInsert include StatusSafeReblogInsert
include StatusSearchConcern
rate_limit by: :account, family: :statuses rate_limit by: :account, family: :statuses
@ -49,6 +50,7 @@ class Status < ApplicationRecord
attr_accessor :override_timestamps attr_accessor :override_timestamps
update_index('statuses', :proper) update_index('statuses', :proper)
update_index('public_statuses', :proper)
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _suffix: :visibility enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _suffix: :visibility
@ -72,6 +74,12 @@ class Status < ApplicationRecord
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify has_many :media_attachments, dependent: :nullify
# Those associations are used for the private search index
has_many :local_mentioned, -> { merge(Account.local) }, through: :active_mentions, source: :account
has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account
has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account
has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account
has_and_belongs_to_many :tags has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards has_and_belongs_to_many :preview_cards
@ -172,37 +180,6 @@ class Status < ApplicationRecord
"v3:#{super}" "v3:#{super}"
end end
def searchable_by(preloaded = nil)
ids = []
ids << account_id if local?
if preloaded.nil?
ids += mentions.joins(:account).merge(Account.local).active.pluck(:account_id)
ids += favourites.joins(:account).merge(Account.local).pluck(:account_id)
ids += reblogs.joins(:account).merge(Account.local).pluck(:account_id)
ids += bookmarks.joins(:account).merge(Account.local).pluck(:account_id)
ids += poll.votes.joins(:account).merge(Account.local).pluck(:account_id) if poll.present?
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || []
ids += preloaded.votes[id] || []
end
ids.uniq
end
def searchable_text
[
spoiler_text,
FormattingHelper.extract_status_plain_text(self),
preloadable_poll ? preloadable_poll.options.join("\n\n") : nil,
ordered_media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
end
def to_log_human_identifier def to_log_human_identifier
account.acct account.acct
end end
@ -277,6 +254,10 @@ class Status < ApplicationRecord
preview_cards.any? preview_cards.any?
end end
def with_poll?
preloadable_poll.present?
end
def non_sensitive_with_media? def non_sensitive_with_media?
!sensitive? && with_media? !sensitive? && with_media?
end end

@ -52,6 +52,7 @@ class UserSettings
setting :link_trends, default: false setting :link_trends, default: false
setting :status_trends, default: false setting :status_trends, default: false
setting :appeal, default: true setting :appeal, default: true
setting :software_updates, default: 'critical', in: %w(none critical patch all)
end end
namespace :interactions do namespace :interactions do

@ -0,0 +1,7 @@
# frozen_string_literal: true
class SoftwareUpdatePolicy < ApplicationPolicy
def index?
role.can?(:view_devops)
end
end

@ -3,9 +3,13 @@
class InitialStatePresenter < ActiveModelSerializers::Model class InitialStatePresenter < ActiveModelSerializers::Model
attributes :settings, :push_subscription, :token, attributes :settings, :push_subscription, :token,
:current_account, :admin, :owner, :text, :visibility, :current_account, :admin, :owner, :text, :visibility,
:disabled_account, :moved_to_account :disabled_account, :moved_to_account, :critical_updates_pending
def role def role
current_account&.user_role current_account&.user_role
end end
def critical_updates_pending
role&.can?(:view_devops) && SoftwareUpdate.urgent_pending?
end
end end

@ -8,13 +8,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :manually_approves_followers, :featured, :also_known_as, context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :olm, :suspended, :moved_to, :property_value, :discoverable, :olm, :suspended,
:memorial :memorial, :indexable
attributes :id, :type, :following, :followers, attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags, :inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary, :preferred_username, :name, :summary,
:url, :manually_approves_followers, :url, :manually_approves_followers,
:discoverable, :published, :memorial :discoverable, :indexable, :published, :memorial
has_one :public_key, serializer: ActivityPub::PublicKeySerializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer
@ -99,6 +99,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.suspended? ? false : (object.discoverable || false) object.suspended? ? false : (object.discoverable || false)
end end
def indexable
object.suspended? ? false : (object.indexable || false)
end
def name def name
object.suspended? ? object.username : (object.display_name.presence || object.username) object.suspended? ? object.username : (object.display_name.presence || object.username)
end end

@ -8,6 +8,8 @@ class InitialStateSerializer < ActiveModel::Serializer
:max_toot_chars, :poll_limits, :max_toot_chars, :poll_limits,
:languages :languages
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
has_one :role, serializer: REST::RoleSerializer has_one :role, serializer: REST::RoleSerializer

@ -18,18 +18,31 @@ class WebfingerSerializer < ActiveModel::Serializer
end end
def links def links
if object.instance_actor? [
[ { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href },
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) }, { rel: 'self', type: 'application/activity+json', href: self_href },
{ rel: 'self', type: 'application/activity+json', href: instance_actor_url }, { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, ].tap do |x|
] x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar?
else
[
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) },
{ rel: 'self', type: 'application/activity+json', href: account_url(object) },
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
]
end end
end end
private
def show_avatar?
media_present = object.avatar.present? && object.avatar.content_type.present?
# Show avatar only if an instance shows profiles to logged out users
allowed_by_config = ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] != 'true' && !Rails.configuration.x.limited_federation_mode
media_present && allowed_by_config
end
def profile_page_href
object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
end
def self_href
object.instance_actor? ? instance_actor_url : account_url(object)
end
end end

@ -38,7 +38,10 @@ class BatchedRemoveStatusService < BaseService
# Since we skipped all callbacks, we also need to manually # Since we skipped all callbacks, we also need to manually
# deindex the statuses # deindex the statuses
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs) if Chewy.enabled? if Chewy.enabled?
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs)
Chewy.strategy.current.update(PublicStatusesIndex, statuses_and_reblogs)
end
return if options[:skip_side_effects] return if options[:skip_side_effects]

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Payloadable module Payloadable
include AuthorizedFetchHelper
# @param [ActiveModelSerializers::Model] record # @param [ActiveModelSerializers::Model] record
# @param [ActiveModelSerializers::Serializer] serializer # @param [ActiveModelSerializers::Serializer] serializer
# @param [Hash] options # @param [Hash] options
@ -23,6 +25,6 @@ module Payloadable
end end
def signing_enabled? def signing_enabled?
ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.limited_federation_mode !authorized_fetch_mode?
end end
end end

@ -1,8 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class SearchService < BaseService class SearchService < BaseService
QUOTE_EQUIVALENT_CHARACTERS = /[“”„«»「」『』《》]/
def call(query, account, limit, options = {}) def call(query, account, limit, options = {})
@query = query&.strip @query = query&.strip&.gsub(QUOTE_EQUIVALENT_CHARACTERS, '"')
@account = account @account = account
@options = options @options = options
@limit = limit.to_i @limit = limit.to_i
@ -17,7 +19,7 @@ class SearchService < BaseService
results.merge!(url_resource_results) unless url_resource.nil? || @offset.positive? || (@options[:type].present? && url_resource_symbol != @options[:type].to_sym) results.merge!(url_resource_results) unless url_resource.nil? || @offset.positive? || (@options[:type].present? && url_resource_symbol != @options[:type].to_sym)
elsif @query.present? elsif @query.present?
results[:accounts] = perform_accounts_search! if account_searchable? results[:accounts] = perform_accounts_search! if account_searchable?
results[:statuses] = perform_statuses_search! if full_text_searchable? results[:statuses] = perform_statuses_search! if status_searchable?
results[:hashtags] = perform_hashtags_search! if hashtag_searchable? results[:hashtags] = perform_hashtags_search! if hashtag_searchable?
end end
end end
@ -39,25 +41,15 @@ class SearchService < BaseService
end end
def perform_statuses_search! def perform_statuses_search!
definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id })) StatusesSearchService.new.call(
@query,
definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present? @account,
limit: @limit,
if @options[:min_id].present? || @options[:max_id].present? offset: @offset,
range = {} account_id: @options[:account_id],
range[:gt] = @options[:min_id].to_i if @options[:min_id].present? min_id: @options[:min_id],
range[:lt] = @options[:max_id].to_i if @options[:max_id].present? max_id: @options[:max_id]
definition = definition.filter(range: { id: range }) )
end
results = definition.limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
preloaded_relations = @account.relations_map(account_ids, account_domains)
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end end
def perform_hashtags_search! def perform_hashtags_search!
@ -89,18 +81,16 @@ class SearchService < BaseService
url_resource.class.name.downcase.pluralize.to_sym url_resource.class.name.downcase.pluralize.to_sym
end end
def full_text_searchable? def status_searchable?
return false unless Chewy.enabled? Chewy.enabled? && status_search? && @account.present?
statuses_search? && !@account.nil? && !(@query.include?('@') && !@query.include?(' '))
end end
def account_searchable? def account_searchable?
account_search? && !(@query.include?('@') && @query.include?(' ')) account_search?
end end
def hashtag_searchable? def hashtag_searchable?
hashtag_search? && !@query.include?('@') hashtag_search?
end end
def account_search? def account_search?
@ -111,11 +101,7 @@ class SearchService < BaseService
@options[:type].blank? || @options[:type] == 'hashtags' @options[:type].blank? || @options[:type] == 'hashtags'
end end
def statuses_search? def status_search?
@options[:type].blank? || @options[:type] == 'statuses' @options[:type].blank? || @options[:type] == 'statuses'
end end
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
end
end end

@ -0,0 +1,82 @@
# frozen_string_literal: true
class SoftwareUpdateCheckService < BaseService
def call
clean_outdated_updates!
return unless SoftwareUpdate.check_enabled?
process_update_notices!(fetch_update_notices)
end
private
def clean_outdated_updates!
SoftwareUpdate.find_each do |software_update|
software_update.delete if Mastodon::Version.gem_version >= software_update.gem_version
rescue ArgumentError
software_update.delete
end
end
def fetch_update_notices
Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res|
return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200
end
rescue HTTP::Error, OpenSSL::SSL::SSLError, Oj::ParseError
nil
end
def api_url
ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check')
end
def version
@version ||= Mastodon::Version.to_s.split('+')[0]
end
def process_update_notices!(update_notices)
return if update_notices.blank? || update_notices['updatesAvailable'].blank?
# Clear notices that are not listed by the update server anymore
SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].pluck('version')).delete_all
# Check if any of the notices is new, and issue notifications
known_versions = SoftwareUpdate.where(version: update_notices['updatesAvailable'].pluck('version')).pluck(:version)
new_update_notices = update_notices['updatesAvailable'].filter { |notice| known_versions.exclude?(notice['version']) }
return if new_update_notices.blank?
new_updates = new_update_notices.map do |notice|
SoftwareUpdate.create!(version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes'])
end
notify_devops!(new_updates)
end
def should_notify_user?(user, urgent_version, patch_version)
case user.settings['notification_emails.software_updates']
when 'none'
false
when 'critical'
urgent_version
when 'patch'
urgent_version || patch_version
when 'all'
true
end
end
def notify_devops!(new_updates)
has_new_urgent_version = new_updates.any?(&:urgent?)
has_new_patch_version = new_updates.any?(&:patch_type?)
User.those_who_can(:view_devops).includes(:account).find_each do |user|
next unless should_notify_user?(user, has_new_urgent_version, has_new_patch_version)
if has_new_urgent_version
AdminMailer.with(recipient: user.account).new_critical_software_updates.deliver_later
else
AdminMailer.with(recipient: user.account).new_software_updates.deliver_later
end
end
end
end

@ -0,0 +1,64 @@
# frozen_string_literal: true
class StatusesSearchService < BaseService
def call(query, account = nil, options = {})
@query = query&.strip
@account = account
@options = options
@limit = options[:limit].to_i
@offset = options[:offset].to_i
status_search_results
end
private
def status_search_results
definition = parsed_query.apply(
Chewy::Search::Request.new(StatusesIndex, PublicStatusesIndex).filter(
bool: {
should: [
publicly_searchable,
non_publicly_searchable,
],
minimum_should_match: 1,
}
)
)
results = definition.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
preloaded_relations = @account.relations_map(account_ids, account_domains)
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end
def publicly_searchable
{
term: { _index: PublicStatusesIndex.index_name },
}
end
def non_publicly_searchable
{
bool: {
must: [
{
term: { _index: StatusesIndex.index_name },
},
{
term: { searchable_by: @account.id },
},
],
},
}
end
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query), current_account: @account)
end
end

@ -42,6 +42,11 @@
.fields-group .fields-group
= f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, recommended: :recommended = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, recommended: :recommended
%h4= t('admin.settings.security.federation_authentication')
.fields-group
= f.input :authorized_fetch, as: :boolean, wrapper: :with_label, label: t('admin.settings.security.authorized_fetch'), warning_hint: authorized_fetch_overridden? ? t('admin.settings.security.authorized_fetch_overridden_hint') : nil, hint: t('admin.settings.security.authorized_fetch_hint'), disabled: authorized_fetch_overridden?, recommended: authorized_fetch_overridden? ? :overridden : nil
%h4= t('admin.settings.discovery.follow_recommendations') %h4= t('admin.settings.discovery.follow_recommendations')
.fields-group .fields-group

@ -0,0 +1,29 @@
- content_for :page_title do
= t('admin.software_updates.title')
.simple_form
%p.lead
= t('admin.software_updates.description')
= link_to t('admin.software_updates.documentation_link'), 'https://docs.joinmastodon.org/admin/upgrading/#automated_checks', target: '_new'
%hr.spacer
- unless @software_updates.empty?
.table-wrapper
%table.table
%thead
%tr
%th= t('admin.software_updates.version')
%th= t('admin.software_updates.type')
%th
%th
%tbody
- @software_updates.each do |update|
%tr
%td= update.version
%td= t("admin.software_updates.types.#{update.type}")
- if update.urgent?
%td.critical= t("admin.software_updates.critical_update")
- else
%td
%td= table_link_to 'link', t('admin.software_updates.release_notes'), update.release_notes

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

Loading…
Cancel
Save