th: Merge remote-tracking branch 'glitch/main'

th-downstream
Kouhai 1 year ago
commit 9d32bdbcde

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

@ -4,6 +4,6 @@ ALTERNATE_DOMAINS=mastodon.internal
DB_HOST=$PWD/data/postgres DB_HOST=$PWD/data/postgres
DB_USER=mastodon DB_USER=mastodon
DB_NAME=mastodon_dev DB_NAME=mastodon_dev
REDIS_URL=unix://./data/redis/redis-dev.sock REDIS_URL=./data/redis/redis-dev.sock
TH_USE_INVITE_QUOTA=1 TH_USE_INVITE_QUOTA=1

@ -4,11 +4,16 @@ on:
platforms: platforms:
required: true required: true
type: string type: string
cache:
type: boolean
default: true
use_native_arm64_builder: use_native_arm64_builder:
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
@ -22,7 +27,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v2
if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder
@ -74,8 +79,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,12 +86,14 @@ 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 }}
push: ${{ inputs.push_to_images != '' }} push: ${{ inputs.push_to_images != '' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: ${{ inputs.cache && 'type=gha' || '' }}
cache-to: type=gha,mode=max cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }}

@ -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
@ -26,10 +26,10 @@ jobs:
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
use_native_arm64_builder: false use_native_arm64_builder: false
cache: 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 +37,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

@ -18,12 +18,12 @@ jobs:
steps: steps:
# Repository needs to be cloned so `git rev-parse` below works # Repository needs to be cloned so `git rev-parse` below works
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- 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,10 @@ 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
# Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages
cache: false
# 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: |

@ -25,7 +25,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev run: sudo apt-get install -y libicu-dev libidn11-dev

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Install system dependencies - name: Install system dependencies
run: | run: |

@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL

@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Increase Git http.postBuffer - name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version? # This is needed due to a bug in Ubuntu's cURL version?

@ -20,7 +20,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v1 uses: crowdin/github-action@v1

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Install native Ruby dependencies
run: | run: |

@ -37,7 +37,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev run: sudo apt-get install -y libicu-dev libidn11-dev

@ -31,7 +31,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3

@ -70,7 +70,7 @@ jobs:
BUNDLE_RETRY: 3 BUNDLE_RETRY: 3
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Install native Ruby dependencies
run: | run: |

@ -69,7 +69,7 @@ jobs:
BUNDLE_RETRY: 3 BUNDLE_RETRY: 3
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Install native Ruby dependencies
run: | run: |

@ -32,7 +32,7 @@ jobs:
SECRET_KEY_BASE: precompile_placeholder SECRET_KEY_BASE: precompile_placeholder
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -127,7 +127,7 @@ jobs:
- 3 - 3
- 4 - 4
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@ -202,7 +202,7 @@ jobs:
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@ -250,3 +250,116 @@ jobs:
with: with:
name: e2e-screenshots name: e2e-screenshots
path: tmp/screenshots/ path: tmp/screenshots/
test-search:
name: Testing search
runs-on: ubuntu-latest
needs:
- build
services:
postgres:
image: postgres:14-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.13
env:
discovery.type: single-node
xpack.security.enabled: false
options: >-
--health-cmd "curl http://localhost:9200/_cluster/health"
--health-interval 10s
--health-timeout 5s
--health-retries 10
ports:
- 9200:9200
env:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: true
RAILS_ENV: test
BUNDLE_WITH: test
ES_ENABLED: true
ES_HOST: localhost
ES_PORT: 9200
strategy:
fail-fast: false
matrix:
ruby-version:
- '3.0'
- '3.1'
- '.ruby-version'
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
path: './public'
name: ${{ github.sha }}
- name: Update package index
run: sudo apt-get update
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev
- name: Install additional system dependencies
run: sudo apt-get install -y ffmpeg imagemagick
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version}}
bundler-cache: true
- run: yarn --frozen-lockfile
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
- run: bundle exec rake spec:search
- name: Archive logs
uses: actions/upload-artifact@v3
if: failure()
with:
name: test-search-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: test-search-screenshots
path: tmp/screenshots/

@ -1 +1 @@
16.20 20.6

@ -37,7 +37,7 @@ Layout/HashAlignment:
Layout/LeadingCommentSpace: Layout/LeadingCommentSpace:
Exclude: Exclude:
- 'config/application.rb' - 'config/application.rb'
- 'config/initializers/omniauth.rb' - 'config/initializers/3_omniauth.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.
@ -86,7 +86,7 @@ Lint/UnusedBlockArgument:
Lint/UselessAssignment: Lint/UselessAssignment:
Exclude: Exclude:
- 'app/services/activitypub/process_status_update_service.rb' - 'app/services/activitypub/process_status_update_service.rb'
- 'config/initializers/omniauth.rb' - 'config/initializers/3_omniauth.rb'
- 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb'
- 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb'
- 'spec/controllers/api/v1/favourites_controller_spec.rb' - 'spec/controllers/api/v1/favourites_controller_spec.rb'
@ -576,11 +576,11 @@ Style/FetchEnvVar:
- 'config/environments/development.rb' - 'config/environments/development.rb'
- 'config/environments/production.rb' - 'config/environments/production.rb'
- 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/2_limited_federation_mode.rb'
- 'config/initializers/3_omniauth.rb'
- 'config/initializers/blacklists.rb' - 'config/initializers/blacklists.rb'
- 'config/initializers/cache_buster.rb' - 'config/initializers/cache_buster.rb'
- 'config/initializers/content_security_policy.rb' - 'config/initializers/content_security_policy.rb'
- 'config/initializers/devise.rb' - 'config/initializers/devise.rb'
- 'config/initializers/omniauth.rb'
- 'config/initializers/paperclip.rb' - 'config/initializers/paperclip.rb'
- 'config/initializers/vapid.rb' - 'config/initializers/vapid.rb'
- 'lib/mastodon/premailer_webpack_strategy.rb' - 'lib/mastodon/premailer_webpack_strategy.rb'
@ -814,7 +814,7 @@ Style/StringLiterals:
# AllowedMethods: define_method, mail, respond_to # AllowedMethods: define_method, mail, respond_to
Style/SymbolProc: Style/SymbolProc:
Exclude: Exclude:
- 'config/initializers/omniauth.rb' - 'config/initializers/3_omniauth.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, AllowSafeAssignment. # Configuration parameters: EnforcedStyle, AllowSafeAssignment.

@ -8,16 +8,23 @@ The following changelog entries focus on changes visible to users, administrator
### Added ### Added
- **Add full-text search of opted-in public posts and rework search operators** ([Gargron](https://github.com/mastodon/mastodon/pull/26485), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26344), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26657), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26650), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26659), [Gargron](https://github.com/mastodon/mastodon/pull/26660), [Gargron](https://github.com/mastodon/mastodon/pull/26663), [Gargron](https://github.com/mastodon/mastodon/pull/26688), [Gargron](https://github.com/mastodon/mastodon/pull/26689), [Gargron](https://github.com/mastodon/mastodon/pull/26686), [Gargron](https://github.com/mastodon/mastodon/pull/26687), [Gargron](https://github.com/mastodon/mastodon/pull/26692), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26697), [Gargron](https://github.com/mastodon/mastodon/pull/26699), [Gargron](https://github.com/mastodon/mastodon/pull/26701), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26710), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26739), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26754), [Gargron](https://github.com/mastodon/mastodon/pull/26662), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26755), [Gargron](https://github.com/mastodon/mastodon/pull/26781), [Gargron](https://github.com/mastodon/mastodon/pull/26782), [Gargron](https://github.com/mastodon/mastodon/pull/26760), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26756), [Gargron](https://github.com/mastodon/mastodon/pull/26784), [Gargron](https://github.com/mastodon/mastodon/pull/26807), [Gargron](https://github.com/mastodon/mastodon/pull/26835), [Gargron](https://github.com/mastodon/mastodon/pull/26847), [Gargron](https://github.com/mastodon/mastodon/pull/26834), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26893), [tribela](https://github.com/mastodon/mastodon/pull/26896))
This introduces a new `public_statuses` Elasticsearch index for public posts by users who have opted in to their posts being searchable (`toot#indexable` flag).
This also revisits the other indexes to provide more useful indexing, and adds new search operators such as `from:me`, `before:2022-11-01`, `after:2022-11-01`, `during:2022-11-01`, `language:fr`, `has:poll`, or `in:library` (for searching only in posts you have written or interacted with).
Results are now ordered chronologically.
- **Add admin notifications for new Mastodon versions** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26582))
This is done by querying `https://api.joinmastodon.org/update-check` every 30 minutes in a background job.
That URL can be changed using the `UPDATE_CHECK_URL` environment variable, and the feature outright disabled by setting that variable to an empty string (`UPDATE_CHECK_URL=`).
- **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508)) - **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508))
This reorganized scattered privacy and reach settings to a single place, as well as improve their wording. This reorganized scattered privacy and reach settings to a single place, as well as improve their wording.
- **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525)) - **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26606), [Gargron](https://github.com/mastodon/mastodon/pull/26666))
- **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281)) - **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281))
- **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866)) - **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26636))
The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained. The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained.
The `forward` parameter still needs to be set for `forward_to_domains` to be taken into account. The `forward` parameter still needs to be set for `forward_to_domains` to be taken into account.
The forwarded-to domains can only include that of the original author and people being replied to. The forwarded-to domains can only include that of the original author and people being replied to.
- **Add forwarding of reported replies to servers being replied to** ([Gargron](https://github.com/mastodon/mastodon/pull/25341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26189)) - **Add forwarding of reported replies to servers being replied to** ([Gargron](https://github.com/mastodon/mastodon/pull/25341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26189))
- Add direct link to the Single-Sign On provider if there is only one sign up method available ([CSDUMMI](https://github.com/mastodon/mastodon/pull/26083), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26368)) - Add `ONE_CLICK_SSO_LOGIN` environment variable to directly link to the Single-Sign On provider if there is only one sign up method available ([CSDUMMI](https://github.com/mastodon/mastodon/pull/26083), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26368), [CSDUMMI](https://github.com/mastodon/mastodon/pull/26857), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26901))
- **Add webhook templating** ([Gargron](https://github.com/mastodon/mastodon/pull/23289)) - **Add webhook templating** ([Gargron](https://github.com/mastodon/mastodon/pull/23289))
- **Add webhooks for local `status.created`, `status.updated`, `account.updated` and `report.updated`** ([VyrCossont](https://github.com/mastodon/mastodon/pull/24133), [VyrCossont](https://github.com/mastodon/mastodon/pull/24243), [VyrCossont](https://github.com/mastodon/mastodon/pull/24211)) - **Add webhooks for local `status.created`, `status.updated`, `account.updated` and `report.updated`** ([VyrCossont](https://github.com/mastodon/mastodon/pull/24133), [VyrCossont](https://github.com/mastodon/mastodon/pull/24243), [VyrCossont](https://github.com/mastodon/mastodon/pull/24211))
- **Add exclusive lists** ([dariusk](https://github.com/mastodon/mastodon/pull/22048), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25324)) - **Add exclusive lists** ([dariusk](https://github.com/mastodon/mastodon/pull/22048), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25324))
@ -28,29 +35,38 @@ The following changelog entries focus on changes visible to users, administrator
- **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561)) - **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561))
- **Add `S3_DISABLE_CHECKSUM_MODE` environment variable for compatibility with some S3-compatible providers** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435)) - **Add `S3_DISABLE_CHECKSUM_MODE` environment variable for compatibility with some S3-compatible providers** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435))
- **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510)) - **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510))
- **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448)) - **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26605), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26658))
- Add support for `indexable` attribute on remote actors ([Gargron](https://github.com/mastodon/mastodon/pull/26485)) - Add admin API for managing tags ([rrgeorge](https://github.com/mastodon/mastodon/pull/26872))
- Add a link to hashtag timelines from the Trending hashtags moderation interface ([gunchleoc](https://github.com/mastodon/mastodon/pull/26724))
- Add timezone to datetimes in e-mails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26822))
- Add `authorized_fetch` server setting in addition to env var ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25798))
- Add avatar image to webfinger responses ([tvler](https://github.com/mastodon/mastodon/pull/26558))
- Add debug logging on signature verification failure ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26637), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26812))
- Add explicit error messages when DeepL quota is exceeded ([lutoma](https://github.com/mastodon/mastodon/pull/26704))
- Add Elasticsearch/OpenSearch version to “Software” in admin dashboard ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26652))
- Add `data-nosnippet` attribute to remote posts and local posts with `noindex` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26648))
- Add support for federating `memorial` attribute ([rrgeorge](https://github.com/mastodon/mastodon/pull/26583))
- Add Cherokee and Kalmyk to languages dropdown ([gunchleoc](https://github.com/mastodon/mastodon/pull/26012), [gunchleoc](https://github.com/mastodon/mastodon/pull/26013))
- Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573)) - Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573))
- Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489)) - Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489))
This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards). This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards).
- Add missing `instances` option to `tootctl search deploy` ([tribela](https://github.com/mastodon/mastodon/pull/26461))
- Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542)) - Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542))
- Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295)) - Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295))
- Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443)) - Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443))
- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447)) - Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26737))
- Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300)) - Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300))
- Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155)) - Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155))
- Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149)) - Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149))
- Add stricter protocol fields validation for accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25937)) - Add stricter protocol fields validation for accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25937))
- Add support for Azure blob storage ([mistydemeo](https://github.com/mastodon/mastodon/pull/23607), [mistydemeo](https://github.com/mastodon/mastodon/pull/26080)) - Add support for Azure blob storage ([mistydemeo](https://github.com/mastodon/mastodon/pull/23607), [mistydemeo](https://github.com/mastodon/mastodon/pull/26080))
- Add toast with option to open post after publishing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25564), [Signez](https://github.com/mastodon/mastodon/pull/25919)) - Add toast with option to open post after publishing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25564), [Signez](https://github.com/mastodon/mastodon/pull/25919), [Gargron](https://github.com/mastodon/mastodon/pull/26664))
- Add canonical link tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25715)) - Add canonical link tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25715))
- Add button to see results for polls in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25726)) - Add button to see results for polls in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25726))
- Add at-symbol prepended to mention span title ([forsamori](https://github.com/mastodon/mastodon/pull/25684)) - Add at-symbol prepended to mention span title ([forsamori](https://github.com/mastodon/mastodon/pull/25684))
- Add users index on `unconfirmed_email` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25672), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25702)) - Add users index on `unconfirmed_email` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25672), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25702))
- Add superapp index on `oauth_applications` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25670)) - Add superapp index on `oauth_applications` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25670))
- Add index to backups on `user_id` column ([mjankowski](https://github.com/mastodon/mastodon/pull/25647)) - Add index to backups on `user_id` column ([mjankowski](https://github.com/mastodon/mastodon/pull/25647))
- Add onboarding prompt when home feed too slow in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25267), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25556), [Gargron](https://github.com/mastodon/mastodon/pull/25579), [renchap](https://github.com/mastodon/mastodon/pull/25580), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25581), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25617), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25917)) - Add onboarding prompt when home feed too slow in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25267), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25556), [Gargron](https://github.com/mastodon/mastodon/pull/25579), [renchap](https://github.com/mastodon/mastodon/pull/25580), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25581), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25617), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25917), [Gargron](https://github.com/mastodon/mastodon/pull/26829))
- Add `POST /api/v1/conversations/:id/unread` API endpoint to mark a conversation as unread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25509)) - Add `POST /api/v1/conversations/:id/unread` API endpoint to mark a conversation as unread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25509))
- Add `translate="no"` to outgoing mentions and links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25524)) - Add `translate="no"` to outgoing mentions and links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25524))
- Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085)) - Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085))
@ -93,24 +109,31 @@ The following changelog entries focus on changes visible to users, administrator
### Changed ### Changed
- **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499)) - **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499), [renchap](https://github.com/mastodon/mastodon/pull/26614), [renchap](https://github.com/mastodon/mastodon/pull/26615))
- **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302)) - **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302))
- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459)) - **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459), [tribela](https://github.com/mastodon/mastodon/pull/26461), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26593), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26795))
- **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184)) - **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184))
- **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248)) - **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248))
- **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), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26633))
- **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))
This deprecates `statsd` support and disables the sidekiq integration unless `STATSD_SIDEKIQ` is set to `true`. This deprecates `statsd` support and disables the sidekiq integration unless `STATSD_SIDEKIQ` is set to `true`.
This is because the `nsa` gem is unmaintained, and its sidekiq integration is known to add very significant overhead. This is because the `nsa` gem is unmaintained, and its sidekiq integration is known to add very significant overhead.
Later versions of Mastodon will have other ways to get the same metrics. Later versions of Mastodon will have other ways to get the same metrics.
- **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386)) - **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26856))
This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas. This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas.
To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`. To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`.
- Change DCT method used for JPEG encoding to float ([electroCutie](https://github.com/mastodon/mastodon/pull/26675))
- Change from `node-redis` to `ioredis` for streaming ([gmemstr](https://github.com/mastodon/mastodon/pull/26581))
- Change private statuses index to index without crutches ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26713))
- Change video compression parameters ([Gargron](https://github.com/mastodon/mastodon/pull/26631), [Gargron](https://github.com/mastodon/mastodon/pull/26745), [Gargron](https://github.com/mastodon/mastodon/pull/26766))
- Change admin e-mail notification settings to be their own settings group ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26596))
- Change opacity of the delete icon in the search field to be more visible ([AntoninDelFabbro](https://github.com/mastodon/mastodon/pull/26449))
- Change Account Search to prioritize username over display name ([jsgoldstein](https://github.com/mastodon/mastodon/pull/26623))
- Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545)) - Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545))
- Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396)) - Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396))
- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416)) - Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416))
@ -120,9 +143,9 @@ The following changelog entries focus on changes visible to users, administrator
- Change poll form element colors to fit with the rest of the ui ([teeerevor](https://github.com/mastodon/mastodon/pull/26139), [teeerevor](https://github.com/mastodon/mastodon/pull/26162), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26164)) - Change poll form element colors to fit with the rest of the ui ([teeerevor](https://github.com/mastodon/mastodon/pull/26139), [teeerevor](https://github.com/mastodon/mastodon/pull/26162), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26164))
- Change 'favourite' to 'favorite' for American English ([marekr](https://github.com/mastodon/mastodon/pull/24667), [gunchleoc](https://github.com/mastodon/mastodon/pull/26009), [nabijaczleweli](https://github.com/mastodon/mastodon/pull/26109)) - Change 'favourite' to 'favorite' for American English ([marekr](https://github.com/mastodon/mastodon/pull/24667), [gunchleoc](https://github.com/mastodon/mastodon/pull/26009), [nabijaczleweli](https://github.com/mastodon/mastodon/pull/26109))
- Change ActivityStreams representation of suspended accounts to not use a blank `name` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25276)) - Change ActivityStreams representation of suspended accounts to not use a blank `name` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25276))
- Change focus UI for keyboard only input ([teeerevor](https://github.com/mastodon/mastodon/pull/25935), [Gargron](https://github.com/mastodon/mastodon/pull/26125)) - Change focus UI for keyboard only input ([teeerevor](https://github.com/mastodon/mastodon/pull/25935), [Gargron](https://github.com/mastodon/mastodon/pull/26125), [Gargron](https://github.com/mastodon/mastodon/pull/26767))
- Change thread view to scroll to the selected post rather than the post being replied to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24685)) - Change thread view to scroll to the selected post rather than the post being replied to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24685))
- Change links in multi-column mode so tabs are open in single-column mode ([Signez](https://github.com/mastodon/mastodon/pull/25893), [Signez](https://github.com/mastodon/mastodon/pull/26070), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25973)) - Change links in multi-column mode so tabs are open in single-column mode ([Signez](https://github.com/mastodon/mastodon/pull/25893), [Signez](https://github.com/mastodon/mastodon/pull/26070), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25973), [Signez](https://github.com/mastodon/mastodon/pull/26019), [Signez](https://github.com/mastodon/mastodon/pull/26759))
- Change searching with `#` to include account index ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25638)) - Change searching with `#` to include account index ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25638))
- Change label and design of sensitive and unavailable media in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25712), [Gargron](https://github.com/mastodon/mastodon/pull/26135), [Gargron](https://github.com/mastodon/mastodon/pull/26330)) - Change label and design of sensitive and unavailable media in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25712), [Gargron](https://github.com/mastodon/mastodon/pull/26135), [Gargron](https://github.com/mastodon/mastodon/pull/26330))
- Change button colors to increase hover/focus contrast and consistency ([teeerevor](https://github.com/mastodon/mastodon/pull/25677), [Gargron](https://github.com/mastodon/mastodon/pull/25679)) - Change button colors to increase hover/focus contrast and consistency ([teeerevor](https://github.com/mastodon/mastodon/pull/25677), [Gargron](https://github.com/mastodon/mastodon/pull/25679))
@ -141,7 +164,7 @@ The following changelog entries focus on changes visible to users, administrator
- Change vacuum scheduler to also delete expired tokens and unused application records ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24871)) - Change vacuum scheduler to also delete expired tokens and unused application records ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24871))
- Change "Sign in" to "Login" ([Gargron](https://github.com/mastodon/mastodon/pull/24942)) - Change "Sign in" to "Login" ([Gargron](https://github.com/mastodon/mastodon/pull/24942))
- Change domain suspensions to also be checked before trying to fetch unknown remote resources ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24535)) - Change domain suspensions to also be checked before trying to fetch unknown remote resources ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24535))
- Change media components to use aspect-ratio rather than compute height themselves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24686), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24943)) - Change media components to use aspect-ratio rather than compute height themselves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24686), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24943), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26801))
- Change logo version in header based on screen size in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24707)) - Change logo version in header based on screen size in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24707))
- Change label from "For you" to "People" on explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24706)) - Change label from "For you" to "People" on explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24706))
- Change logged-out WebUI HTML pages to be cached for a few seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24708)) - Change logged-out WebUI HTML pages to be cached for a few seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24708))
@ -152,7 +175,7 @@ The following changelog entries focus on changes visible to users, administrator
- Change account search in moderation interface to allow searching by username including the leading `@` ([HeitorMC](https://github.com/mastodon/mastodon/pull/24242)) - Change account search in moderation interface to allow searching by username including the leading `@` ([HeitorMC](https://github.com/mastodon/mastodon/pull/24242))
- Change all components to use the same error page in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24512)) - Change all components to use the same error page in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24512))
- Change search pop-out in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24305)) - Change search pop-out in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24305))
- Change user settings to be stored in a more optimal way ([Gargron](https://github.com/mastodon/mastodon/pull/23630), [c960657](https://github.com/mastodon/mastodon/pull/24321), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24460), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24558), [Gargron](https://github.com/mastodon/mastodon/pull/24761), [Gargron](https://github.com/mastodon/mastodon/pull/24783), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25508), [jsgoldstein](https://github.com/mastodon/mastodon/pull/25340)) - Change user settings to be stored in a more optimal way ([Gargron](https://github.com/mastodon/mastodon/pull/23630), [c960657](https://github.com/mastodon/mastodon/pull/24321), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24460), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24558), [Gargron](https://github.com/mastodon/mastodon/pull/24761), [Gargron](https://github.com/mastodon/mastodon/pull/24783), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25508), [jsgoldstein](https://github.com/mastodon/mastodon/pull/25340), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26884))
- Change media upload limits and remove client-side resizing ([Gargron](https://github.com/mastodon/mastodon/pull/23726)) - Change media upload limits and remove client-side resizing ([Gargron](https://github.com/mastodon/mastodon/pull/23726))
- Change design of account rows in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24247), [Gargron](https://github.com/mastodon/mastodon/pull/24343), [Gargron](https://github.com/mastodon/mastodon/pull/24956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25131)) - Change design of account rows in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24247), [Gargron](https://github.com/mastodon/mastodon/pull/24343), [Gargron](https://github.com/mastodon/mastodon/pull/24956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25131))
- Change log-out to use Single Logout when using external log-in through OIDC ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24020)) - Change log-out to use Single Logout when using external log-in through OIDC ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24020))
@ -175,6 +198,8 @@ The following changelog entries focus on changes visible to users, administrator
- **Remove support for Ruby 2.7** ([nschonni](https://github.com/mastodon/mastodon/pull/24237)) - **Remove support for Ruby 2.7** ([nschonni](https://github.com/mastodon/mastodon/pull/24237))
- **Remove clustering from streaming API** ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24655)) - **Remove clustering from streaming API** ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24655))
- **Remove anonymous access to the streaming API** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23989)) - **Remove anonymous access to the streaming API** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23989))
- Remove obfuscation of reply count in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26768))
- Remove `kmr` from language selection, as it was a duplicate for `ku` ([gunchleoc](https://github.com/mastodon/mastodon/pull/26014), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26787))
- Remove 16:9 cropping from web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26132)) - Remove 16:9 cropping from web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26132))
- Remove back button from bookmarks, favourites and lists screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26126)) - Remove back button from bookmarks, favourites and lists screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26126))
- Remove display name input from sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24704)) - Remove display name input from sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24704))
@ -189,6 +214,26 @@ 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 paragraph margins resulting in irregular read-more cut-off in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26828))
- Fix notification permissions being requested immediately after login ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26472))
- Fix performances of profile directory ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26840), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26842))
- Fix mute button and volume slider feeling disconnected in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26827), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26860))
- Fix “Scoped order is ignored, it's forced to be batch order.” warnings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26793))
- Fix blocked domain appearing in account feeds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26823))
- Fix moderator rights inconsistencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26729))
- Fix crash when encountering invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26814))
- Fix invalid `Content-Type` header for WebP images ([c960657](https://github.com/mastodon/mastodon/pull/26773))
- Fix minor inefficiencies in `tootctl search deploy` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26721))
- Fix filter form in profiles directory overflowing instead of wrapping ([arbolitoloco1](https://github.com/mastodon/mastodon/pull/26682))
- Fix `/api/v1/timelines/tag/:hashtag` allowing for unauthenticated access when public preview is disabled ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26237))
- Fix inefficiencies in `PlainTextFormatter` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26727))
- Fix sign up steps progress layout in right-to-left locales ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26728))
- Fix bug with “favorited by” and “reblogged by“ view on posts only showing up to 40 items ([timothyjrogers](https://github.com/mastodon/mastodon/pull/26577), [timothyjrogers](https://github.com/mastodon/mastodon/pull/26574))
- Fix bad search type heuristic ([Gargron](https://github.com/mastodon/mastodon/pull/26673))
- Fix not being able to negate prefix clauses in search ([Gargron](https://github.com/mastodon/mastodon/pull/26672))
- Fix timeout on invalid set of exclusionary parameters in `/api/v1/timelines/public` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26239))
- Fix unexpected audio stream transcoding when uploaded video is eligible to passthrough ([yufushiro](https://github.com/mastodon/mastodon/pull/26608))
- 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))

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
# This needs to be bullseye-slim because the Ruby image is built on bullseye-slim # This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
ARG NODE_IMAGE=node:18.16-bullseye-slim ARG NODE_VERSION="20.6-bookworm-slim"
ARG RUBY_IMAGE=ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim ARG RUBY_IMAGE=ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@ -25,6 +25,7 @@ RUN --mount=type=cache,id=apt,target=/var/cache/apt,sharing=private \
rm -f /etc/apt/apt.conf.d/docker-clean && \ rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \ echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \
apt-get update && \ apt-get update && \
apt-get -yq dist-upgrade && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
@ -32,7 +33,7 @@ RUN --mount=type=cache,id=apt,target=/var/cache/apt,sharing=private \
libgdbm-dev \ libgdbm-dev \
libgmp-dev \ libgmp-dev \
libicu-dev \ libicu-dev \
libidn11-dev \ libidn-dev \
libjemalloc-dev \ libjemalloc-dev \
libpq-dev \ libpq-dev \
libreadline8 \ libreadline8 \
@ -56,7 +57,7 @@ RUN --mount=type=cache,id=bundle,target=/opt/bundle/cache,sharing=private \
bundle config set cache_path /opt/bundle/cache && \ bundle config set cache_path /opt/bundle/cache && \
bundle config set silence_root_warning 'true' && \ bundle config set silence_root_warning 'true' && \
bundle cache --no-install && \ bundle cache --no-install && \
bundle config set --local deployment 'true' && \ bundle config set --local deployment true && \
bundle install --local -j"$(nproc)" && \ bundle install --local -j"$(nproc)" && \
yarn install --immutable yarn install --immutable
@ -68,8 +69,8 @@ COPY --link . /opt/mastodon
# build # build
FROM build-base AS build FROM build-base AS build
ENV RAILS_ENV="production" \ ENV RAILS_ENV=production \
NODE_ENV="production" NODE_ENV=production
ENV NODE_OPTIONS=--openssl-legacy-provider \ ENV NODE_OPTIONS=--openssl-legacy-provider \
YARN_GLOBAL_FOLDER=/opt/yarn \ YARN_GLOBAL_FOLDER=/opt/yarn \
@ -110,12 +111,12 @@ RUN --mount=type=cache,id=apt,target=/var/cache/apt,sharing=private \
ffmpeg \ ffmpeg \
file \ file \
imagemagick \ imagemagick \
libicu67 \ libicu72 \
libidn11 \ libidn12 \
libjemalloc2 \ libjemalloc2 \
libpq5 \ libpq5 \
libreadline8 \ libreadline8 \
libssl1.1 \ libssl3 \
libyaml-0-2 \ libyaml-0-2 \
procps \ procps \
tini \ tini \
@ -128,8 +129,8 @@ FROM output-base as output
# Use those args to specify your own version flags & suffixes # Use those args to specify your own version flags & suffixes
ARG SOURCE_TAG="" ARG SOURCE_TAG=""
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"
@ -141,7 +142,7 @@ ENV PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"
# Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use # Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use
# hadolint ignore=DL3008,DL3009 # hadolint ignore=DL3008,DL3009
RUN groupadd -g "${GID}" mastodon && \ RUN groupadd -g "${GID}" mastodon && \
useradd -l -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \ useradd -l -u "${UID}" -g "${GID}" -m -d /opt/mastodon mastodon && \
ln -s /opt/mastodon /mastodon ln -s /opt/mastodon /mastodon
# Note: no, cleaning here since Debian does this automatically # Note: no, cleaning here since Debian does this automatically
@ -155,8 +156,8 @@ ENV RAILS_ENV="production" \
RAILS_SERVE_STATIC_FILES="true" \ RAILS_SERVE_STATIC_FILES="true" \
BIND="0.0.0.0" \ BIND="0.0.0.0" \
SOURCE_TAG="${SOURCE_TAG}" \ SOURCE_TAG="${SOURCE_TAG}" \
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \ MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}" MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"
# override this at will # override this at will
ENV BOOTSNAP_READONLY=1 ENV BOOTSNAP_READONLY=1

@ -27,4 +27,5 @@ More information on HTTP Signatures, as well as examples, can be found here: htt
- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld - Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld
- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ - Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/
- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md - Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md

@ -39,47 +39,47 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.7.2) actioncable (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.7.2) actionmailbox (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
activejob (= 7.0.7.2) activejob (= 7.0.8)
activerecord (= 7.0.7.2) activerecord (= 7.0.8)
activestorage (= 7.0.7.2) activestorage (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.7.2) actionmailer (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
actionview (= 7.0.7.2) actionview (= 7.0.8)
activejob (= 7.0.7.2) activejob (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.7.2) actionpack (7.0.8)
actionview (= 7.0.7.2) actionview (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.7.2) actiontext (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
activerecord (= 7.0.7.2) activerecord (= 7.0.8)
activestorage (= 7.0.7.2) activestorage (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.7.2) actionview (7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -89,27 +89,27 @@ GEM
activemodel (>= 4.1, < 7.1) activemodel (>= 4.1, < 7.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.0.7.2) activejob (7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.7.2) activemodel (7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
activerecord (7.0.7.2) activerecord (7.0.8)
activemodel (= 7.0.7.2) activemodel (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
activestorage (7.0.7.2) activestorage (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
activejob (= 7.0.7.2) activejob (= 7.0.8)
activerecord (= 7.0.7.2) activerecord (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.7.2) activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
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
@ -325,7 +325,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
@ -334,7 +334,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
@ -410,7 +410,7 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
kt-paperclip (7.2.0) kt-paperclip (7.2.1)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
marcel (~> 1.0.1) marcel (~> 1.0.1)
@ -483,7 +483,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)
@ -520,8 +520,8 @@ 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.4)
activerecord (>= 6) activerecord (>= 6)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.21.0) premailer (1.21.0)
@ -557,20 +557,20 @@ GEM
rack rack
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.7.2) rails (7.0.8)
actioncable (= 7.0.7.2) actioncable (= 7.0.8)
actionmailbox (= 7.0.7.2) actionmailbox (= 7.0.8)
actionmailer (= 7.0.7.2) actionmailer (= 7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
actiontext (= 7.0.7.2) actiontext (= 7.0.8)
actionview (= 7.0.7.2) actionview (= 7.0.8)
activejob (= 7.0.7.2) activejob (= 7.0.8)
activemodel (= 7.0.7.2) activemodel (= 7.0.8)
activerecord (= 7.0.7.2) activerecord (= 7.0.8)
activestorage (= 7.0.7.2) activestorage (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.7.2) railties (= 7.0.8)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -585,9 +585,9 @@ GEM
rails-i18n (7.0.7) rails-i18n (7.0.7)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
railties (7.0.7.2) railties (7.0.8)
actionpack (= 7.0.7.2) actionpack (= 7.0.8)
activesupport (= 7.0.7.2) activesupport (= 7.0.8)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@ -641,7 +641,7 @@ GEM
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.12.1)
rspec_chunked (0.6) rspec_chunked (0.6)
rubocop (1.56.1) rubocop (1.56.3)
base64 (~> 0.1.1) base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
@ -732,7 +732,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)
@ -746,7 +746,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
test-prof (1.2.2) test-prof (1.2.3)
thor (1.2.2) thor (1.2.2)
tilt (2.2.0) tilt (2.2.0)
timeout (0.4.0) timeout (0.4.0)
@ -796,7 +796,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: env RAILS_ENV=development NODE_ENV=development NODE_OPTIONS=--openssl-legacy-provider ./bin/webpack-dev-server --listen-host 0.0.0.0 webpack: bin/webpack-dev-server

@ -13,9 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
## Supported Versions ## Supported Versions
| Version | Supported | | Version | Supported |
| ------- | --------- | | ------- | ---------------- |
| 4.1.x | Yes | | 4.1.x | Yes |
| 4.0.x | Yes | | 4.0.x | Until 2023-10-31 |
| 3.5.x | Yes | | 3.5.x | Until 2023-12-31 |
| < 3.5 | No | | < 3.5 | No |

3
Vagrantfile vendored

@ -76,7 +76,8 @@ path.logs: /var/log/elasticsearch
network.host: 0.0.0.0 network.host: 0.0.0.0
http.port: 9200 http.port: 9200
discovery.seed_hosts: ["localhost"] discovery.seed_hosts: ["localhost"]
cluster.initial_master_nodes: ["node-1"]' > /etc/elasticsearch/elasticsearch.yml cluster.initial_master_nodes: ["node-1"]
xpack.security.enabled: false' > /etc/elasticsearch/elasticsearch.yml
sudo systemctl restart elasticsearch sudo systemctl restart elasticsearch

@ -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,67 @@
# 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
),
},
hashtag: {
tokenizer: 'keyword',
filter: %w(
word_delimiter_graph
lowercase
asciifolding
cjk_width
),
},
},
}
index_scope ::Status.unscoped
.kept
.indexable
.includes(:media_attachments, :preloadable_poll, :preview_cards, :tags)
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(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) })
field(:language, type: 'keyword')
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date')
end
end

@ -1,75 +1,65 @@
# 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
), ),
}, },
hashtag: {
tokenizer: 'keyword',
filter: %w(
word_delimiter_graph
lowercase
asciifolding
cjk_width
),
},
}, },
} }
# 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, :tags, 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(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) })
field :stemmed, type: 'text', analyzer: 'content' field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
end field(:language, type: 'keyword')
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) } field(:created_at, type: 'date')
end end
end end

@ -5,12 +5,21 @@ class TagsIndex < Chewy::Index
analyzer: { analyzer: {
content: { content: {
tokenizer: 'keyword', tokenizer: 'keyword',
filter: %w(lowercase asciifolding cjk_width), filter: %w(
word_delimiter_graph
lowercase
asciifolding
cjk_width
),
}, },
edge_ngram: { edge_ngram: {
tokenizer: 'edge_ngram', tokenizer: 'edge_ngram',
filter: %w(lowercase asciifolding cjk_width), filter: %w(
lowercase
asciifolding
cjk_width
),
}, },
}, },
@ -30,12 +39,9 @@ class TagsIndex < Chewy::Index
end end
root date_detection: false do root date_detection: false do
field :name, type: 'text', analyzer: 'content' do field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') }
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? })
end field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts })
field(:last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at })
field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
field :usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
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

@ -0,0 +1,74 @@
# frozen_string_literal: true
class Api::V1::Admin::TagsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
before_action :set_tags, only: :index
before_action :set_tag, except: :index
after_action :insert_pagination_headers, only: :index
after_action :verify_authorized
LIMIT = 100
PAGINATION_PARAMS = %i(limit).freeze
def index
authorize :tag, :index?
render json: @tags, each_serializer: REST::Admin::TagSerializer
end
def show
authorize @tag, :show?
render json: @tag, serializer: REST::Admin::TagSerializer
end
def update
authorize @tag, :update?
@tag.update!(tag_params.merge(reviewed_at: Time.now.utc))
render json: @tag, serializer: REST::Admin::TagSerializer
end
private
def set_tag
@tag = Tag.find(params[:id])
end
def set_tags
@tags = Tag.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def tag_params
params.permit(:display_name, :trendable, :usable, :listable)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty?
end
def pagination_max_id
@tags.last.id
end
def pagination_since_id
@tags.first.id
end
def records_continue?
@tags.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
end
end

@ -16,7 +16,9 @@ class Api::V1::DirectoriesController < Api::BaseController
end end
def set_accounts def set_accounts
@accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT)) with_read_replica do
@accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
end
end end
def accounts_scope def accounts_scope

@ -41,5 +41,7 @@ class Api::V1::Peers::SearchController < Api::BaseController
domain = TagManager.instance.normalize_domain(domain) domain = TagManager.instance.normalize_domain(domain)
@domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain)
end end
rescue Addressable::URI::InvalidURIError
@domains = []
end end
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.debug { "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

@ -12,7 +12,7 @@ module WebAppControllerConcern
end end
def skip_csrf_meta_tags? def skip_csrf_meta_tags?
!(ENV['OMNIAUTH_ONLY'] == 'true' && Devise.omniauth_providers.length == 1) && current_user.nil? !(ENV['ONE_CLICK_SSO_LOGIN'] == 'true' && ENV['OMNIAUTH_ONLY'] == 'true' && Devise.omniauth_providers.length == 1) && current_user.nil?
end end
def set_app_body_class def set_app_body_class

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

@ -193,7 +193,6 @@ module LanguagesHelper
cnr: ['Montenegrin', 'crnogorski'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze,
kab: ['Kabyle', 'Taqbaylit'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze,
kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze,
ldn: ['Láadan', 'Láadan'].freeze, ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
sco: ['Scots', 'Scots'].freeze, sco: ['Scots', 'Scots'].freeze,

@ -14,6 +14,7 @@ module MediaComponentHelper
blurhash: video.blurhash, blurhash: video.blurhash,
frameRate: meta.dig('original', 'frame_rate'), frameRate: meta.dig('original', 'frame_rate'),
inline: true, inline: true,
aspectRatio: "#{meta.dig('original', 'width')} / #{meta.dig('original', 'height')}",
media: [ media: [
serialize_media_attachment(video), serialize_media_attachment(video),
].as_json, ].as_json,

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

@ -140,7 +140,9 @@ const fromAcct = (acct: string) => {
}; };
const fetchInteractionURL = (uri_or_domain: string) => { const fetchInteractionURL = (uri_or_domain: string) => {
if (/^https?:\/\//.test(uri_or_domain)) { if (uri_or_domain === '') {
fetchInteractionURLFailure();
} else if (/^https?:\/\//.test(uri_or_domain)) {
fromURL(uri_or_domain); fromURL(uri_or_domain);
} else if (uri_or_domain.includes('@')) { } else if (uri_or_domain.includes('@')) {
fromAcct(uri_or_domain); fromAcct(uri_or_domain);

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

@ -18,6 +18,7 @@ import {
importFetchedStatuses, importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
@ -384,6 +385,10 @@ export function requestBrowserPermission(callback = noOp) {
requestNotificationPermission((permission) => { requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission)); dispatch(setBrowserPermission(permission));
callback(permission); callback(permission);
if (permission === 'granted') {
dispatch(registerPushNotifications());
}
}); });
}; };
} }

@ -1,3 +1,7 @@
import { fromJS } from 'immutable';
import { searchHistory } from 'flavours/glitch/settings';
import api from '../api'; import api from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
@ -15,8 +19,7 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK'; export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
export function changeSearch(value) { export function changeSearch(value) {
return { return {
@ -37,17 +40,17 @@ export function submitSearch(type) {
const signedIn = !!getState().getIn(['meta', 'me']); const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) { if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
return; return;
} }
dispatch(fetchSearchRequest()); dispatch(fetchSearchRequest(type));
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params: { params: {
q: value, q: value,
resolve: signedIn, resolve: signedIn,
limit: 10, limit: 11,
type, type,
}, },
}).then(response => { }).then(response => {
@ -59,7 +62,7 @@ export function submitSearch(type) {
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(response.data.statuses));
} }
dispatch(fetchSearchSuccess(response.data, value)); dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchSearchFail(error)); dispatch(fetchSearchFail(error));
@ -67,16 +70,18 @@ export function submitSearch(type) {
}; };
} }
export function fetchSearchRequest() { export function fetchSearchRequest(searchType) {
return { return {
type: SEARCH_FETCH_REQUEST, type: SEARCH_FETCH_REQUEST,
searchType,
}; };
} }
export function fetchSearchSuccess(results, searchTerm) { export function fetchSearchSuccess(results, searchTerm, searchType) {
return { return {
type: SEARCH_FETCH_SUCCESS, type: SEARCH_FETCH_SUCCESS,
results, results,
searchType,
searchTerm, searchTerm,
}; };
} }
@ -90,15 +95,16 @@ export function fetchSearchFail(error) {
export const expandSearch = type => (dispatch, getState) => { export const expandSearch = type => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']); const value = getState().getIn(['search', 'value']);
const offset = getState().getIn(['search', 'results', type]).size; const offset = getState().getIn(['search', 'results', type]).size - 1;
dispatch(expandSearchRequest()); dispatch(expandSearchRequest(type));
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params: { params: {
q: value, q: value,
type, type,
offset, offset,
limit: 11,
}, },
}).then(({ data }) => { }).then(({ data }) => {
if (data.accounts) { if (data.accounts) {
@ -116,8 +122,9 @@ export const expandSearch = type => (dispatch, getState) => {
}); });
}; };
export const expandSearchRequest = () => ({ export const expandSearchRequest = (searchType) => ({
type: SEARCH_EXPAND_REQUEST, type: SEARCH_EXPAND_REQUEST,
searchType,
}); });
export const expandSearchSuccess = (results, searchTerm, searchType) => ({ export const expandSearchSuccess = (results, searchTerm, searchType) => ({
@ -161,16 +168,34 @@ export const openURL = routerHistory => (dispatch, getState) => {
}); });
}; };
export const clickSearchResult = (q, type) => ({ export const clickSearchResult = (q, type) => (dispatch, getState) => {
type: SEARCH_RESULT_CLICK, const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
result: { searchHistory.set(me, current.toJS());
type, dispatch(updateSearchHistory(current));
q, };
},
}); export const forgetSearchResult = q => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.filterNot(result => result.get('q') === q);
export const forgetSearchResult = q => ({ searchHistory.set(me, current.toJS());
type: SEARCH_RESULT_FORGET, dispatch(updateSearchHistory(current));
q, };
export const updateSearchHistory = recent => ({
type: SEARCH_HISTORY_UPDATE,
recent,
}); });
export const hydrateSearch = () => (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = searchHistory.get(me);
if (history !== null) {
dispatch(updateSearchHistory(history));
}
};

@ -2,6 +2,7 @@ import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose'; import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { hydrateSearch } from './search';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE = 'STORE_HYDRATE';
@ -34,6 +35,7 @@ export function hydrateStore(rawState) {
}); });
dispatch(hydrateCompose()); dispatch(hydrateCompose());
dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts))); dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
dispatch(saveSettings()); dispatch(saveSettings());
}; };

@ -33,8 +33,6 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<div className='dismissable-banner'> <div className='dismissable-banner'>
<div className='dismissable-banner__message'>{children}</div>
<div className='dismissable-banner__action'> <div className='dismissable-banner__action'>
<IconButton <IconButton
icon='times' icon='times'
@ -42,6 +40,8 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
onClick={handleDismiss} onClick={handleDismiss}
/> />
</div> </div>
<div className='dismissable-banner__message'>{children}</div>
</div> </div>
); );
}; };

@ -793,6 +793,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}

@ -212,11 +212,11 @@ class Audio extends PureComponent {
}; };
toggleMute = () => { toggleMute = () => {
const muted = !this.state.muted; const muted = !(this.state.muted || this.state.volume === 0);
this.setState({ muted }, () => { this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
if (this.gainNode) { if (this.gainNode) {
this.gainNode.gain.value = muted ? 0 : this.state.volume; this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
} }
}); });
}; };
@ -294,7 +294,7 @@ class Audio extends PureComponent {
const { x } = getPointerPosition(this.volume, e); const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) { if(!isNaN(x)) {
this.setState({ volume: x }, () => { this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
if (this.gainNode) { if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : x; this.gainNode.gain.value = this.state.muted ? 0 : x;
} }
@ -473,8 +473,9 @@ class Audio extends PureComponent {
render () { render () {
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props; const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state; const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100); const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
let warning; let warning;
@ -564,12 +565,12 @@ class Audio extends PureComponent {
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> <div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span <span
className='video-player__volume__handle' className='video-player__volume__handle'
tabIndex={0} tabIndex={0}
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
/> />
</div> </div>

@ -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';
@ -13,7 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { searchEnabled } from 'flavours/glitch/initial_state'; import { domain, searchEnabled } from 'flavours/glitch/initial_state';
import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags'; import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
@ -22,7 +18,17 @@ const messages = defineMessages({
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' }, placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
}); });
// The component. const labelForRecentSearch = search => {
switch(search.get('type')) {
case 'account':
return `@${search.get('q')}`;
case 'hashtag':
return `#${search.get('q')}`;
default:
return search.get('q');
}
};
class Search extends PureComponent { class Search extends PureComponent {
static contextTypes = { static contextTypes = {
@ -52,6 +58,17 @@ 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:') } },
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:') } }
];
setRef = c => { setRef = c => {
this.searchForm = c; this.searchForm = c;
}; };
@ -100,7 +117,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 +148,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 +177,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 +188,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 +196,7 @@ class Search extends PureComponent {
const { onOpenURL } = this.props; const { onOpenURL } = this.props;
onOpenURL(router.history); onOpenURL(router.history);
this._unfocus();
}; };
handleStatusSearch = () => { handleStatusSearch = () => {
@ -189,13 +208,19 @@ class Search extends PureComponent {
}; };
handleRecentSearchClick = search => { handleRecentSearchClick = search => {
const { onChange } = this.props;
const { router } = this.context; const { router } = this.context;
if (search.get('type') === 'account') { if (search.get('type') === 'account') {
router.history.push(`/@${search.get('q')}`); router.history.push(`/@${search.get('q')}`);
} 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')}`);
} else {
onChange(search.get('q'));
this._submit(search.get('type'));
} }
this._unfocus();
}; };
handleForgetRecentSearchClick = search => { handleForgetRecentSearchClick = search => {
@ -208,15 +233,33 @@ 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, value, onClickSearchResult } = this.props;
const { router } = this.context; const { router } = this.context;
onSubmit(type); onSubmit(type);
if (value) {
onClickSearchResult(value, type);
}
if (openInRoute) { if (openInRoute) {
router.history.push('/search'); router.history.push('/search');
} }
this._unfocus();
} }
_getOptions () { _getOptions () {
@ -229,7 +272,7 @@ class Search extends PureComponent {
const { recent } = this.props; const { recent } = this.props;
return recent.toArray().map(search => ({ return recent.toArray().map(search => ({
label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`, label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search), action: () => this.handleRecentSearchClick(search),
@ -337,6 +380,22 @@ class Search extends PureComponent {
</div> </div>
</> </>
)} )}
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
{searchEnabled ? (
<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 || recent.size) + i) })}>
{label}
</button>
))}
</div>
) : (
<div className='search__popout__menu__message'>
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
</div>
)}
</div> </div>
</div> </div>
); );

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@ -10,36 +10,26 @@ import { Icon } from 'flavours/glitch/components/icon';
import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadMore } from 'flavours/glitch/components/load_more';
import AccountContainer from 'flavours/glitch/containers/account_container'; import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container'; import StatusContainer from 'flavours/glitch/containers/status_container';
import { searchEnabled } from 'flavours/glitch/initial_state'; import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
const messages = defineMessages({ const INITIAL_PAGE_LIMIT = 10;
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
}); const withoutLastResult = list => {
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
return list.skipLast(1);
} else {
return list;
}
};
class SearchResults extends ImmutablePureComponent { class SearchResults extends ImmutablePureComponent {
static propTypes = { static propTypes = {
results: ImmutablePropTypes.map.isRequired, results: ImmutablePropTypes.map.isRequired,
suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
expandSearch: PropTypes.func.isRequired, expandSearch: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired,
searchTerm: PropTypes.string, searchTerm: PropTypes.string,
intl: PropTypes.object.isRequired,
}; };
componentDidMount () {
if (this.props.searchTerm === '') {
this.props.fetchSuggestions();
}
}
componentDidUpdate () {
if (this.props.searchTerm === '') {
this.props.fetchSuggestions();
}
}
handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
@ -47,98 +37,51 @@ class SearchResults extends ImmutablePureComponent {
handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
render () { render () {
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; const { results } = this.props;
let accounts, statuses, hashtags; let accounts, statuses, hashtags;
let count = 0;
if (searchTerm === '' && !suggestions.isEmpty()) {
return (
<div className='drawer--results'>
<div className='trends'>
<div className='trends__header'>
<Icon fixedWidth id='user-plus' />
<FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
</div>
{suggestions && suggestions.map(suggestion => (
<AccountContainer
key={suggestion.get('account')}
id={suggestion.get('account')}
actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
onActionClick={dismissSuggestion}
/>
))}
</div>
</div>
);
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
statuses = (
<section className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
<div className='search-results__info'>
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' />
</div>
</section>
);
}
if (results.get('accounts') && results.get('accounts').size > 0) { if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = ( accounts = (
<section className='search-results__section'> <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5> {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
{results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)} </SearchSection>
{results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
</section>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<section className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
{results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId} />)}
{results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
</section>
); );
} }
if (results.get('hashtags') && results.get('hashtags').size > 0) { if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = ( hashtags = (
<section className='search-results__section'> <SearchSection title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
<h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} </SearchSection>
);
}
{results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} if (results.get('statuses') && results.get('statuses').size > 0) {
</section> statuses = (
<SearchSection title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
</SearchSection>
); );
} }
// The result.
return ( return (
<div className='drawer--results'> <div className='drawer--results'>
<header className='search-results__header'> <header className='search-results__header'>
<Icon id='search' fixedWidth /> <Icon id='search' fixedWidth />
<FormattedMessage id='search_results.total' defaultMessage='{count, plural, one {# result} other {# results}}' values={{ count }} /> <FormattedMessage id='explore.search_results' defaultMessage='Search results' />
</header> </header>
{accounts} {accounts}
{statuses}
{hashtags} {hashtags}
{statuses}
</div> </div>
); );
} }
} }
export default injectIntl(SearchResults); export default SearchResults;

@ -15,7 +15,7 @@ import Search from '../components/search';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['search', 'value']), value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']), submitted: state.getIn(['search', 'submitted']),
recent: state.getIn(['search', 'recent']), recent: state.getIn(['search', 'recent']).reverse(),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
export const SearchSection = ({ title, onClickMore, children }) => (
<div className='search-results__section'>
<div className='search-results__section__header'>
<h3>{title}</h3>
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
</div>
{children}
</div>
);
SearchSection.propTypes = {
title: PropTypes.node.isRequired,
onClickMore: PropTypes.func,
children: PropTypes.children,
};

@ -9,14 +9,14 @@ import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { expandSearch } from 'flavours/glitch/actions/search'; import { submitSearch, expandSearch } from 'flavours/glitch/actions/search';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag'; import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import { LoadMore } from 'flavours/glitch/components/load_more'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import Account from 'flavours/glitch/containers/account_container'; import Account from 'flavours/glitch/containers/account_container';
import Status from 'flavours/glitch/containers/status_container'; import Status from 'flavours/glitch/containers/status_container';
import { SearchSection } from './components/search_section';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
@ -26,85 +26,175 @@ const mapStateToProps = state => ({
isLoading: state.getIn(['search', 'isLoading']), isLoading: state.getIn(['search', 'isLoading']),
results: state.getIn(['search', 'results']), results: state.getIn(['search', 'results']),
q: state.getIn(['search', 'searchTerm']), q: state.getIn(['search', 'searchTerm']),
submittedType: state.getIn(['search', 'type']),
}); });
const appendLoadMore = (id, list, onLoadMore) => { const INITIAL_PAGE_LIMIT = 10;
if (list.size >= 5) { const INITIAL_DISPLAY = 4;
return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
const hidePeek = list => {
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
return list.skipLast(1);
} else { } else {
return list; return list;
} }
}; };
const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => ( const renderAccounts = accounts => hidePeek(accounts).map(id => (
<Account key={`account-${item}`} id={item} /> <Account key={id} id={id} />
)), onLoadMore); ));
const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => ( const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
<Hashtag key={`tag-${item.get('name')}`} hashtag={item} /> <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
)), onLoadMore); ));
const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => ( const renderStatuses = statuses => hidePeek(statuses).map(id => (
<Status key={`status-${item}`} id={item} /> <Status key={id} id={id} />
)), onLoadMore); ));
class Results extends PureComponent { class Results extends PureComponent {
static propTypes = { static propTypes = {
results: ImmutablePropTypes.map, results: ImmutablePropTypes.contains({
accounts: ImmutablePropTypes.orderedSet,
statuses: ImmutablePropTypes.orderedSet,
hashtags: ImmutablePropTypes.orderedSet,
}),
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
q: PropTypes.string, q: PropTypes.string,
intl: PropTypes.object, intl: PropTypes.object,
submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
}; };
state = { state = {
type: 'all', type: this.props.submittedType || 'all',
};
static getDerivedStateFromProps(props, state) {
if (props.submittedType !== state.type) {
return {
type: props.submittedType || 'all',
};
}
return null;
};
handleSelectAll = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for a specific type, we need to resubmit
// the query to get all types of results
if (submittedType) {
dispatch(submitSearch());
}
this.setState({ type: 'all' });
}; };
handleSelectAll = () => this.setState({ type: 'all' }); handleSelectAccounts = () => {
handleSelectAccounts = () => this.setState({ type: 'accounts' }); const { submittedType, dispatch } = this.props;
handleSelectHashtags = () => this.setState({ type: 'hashtags' });
handleSelectStatuses = () => this.setState({ type: 'statuses' }); // If we originally searched for something else (but not everything),
handleLoadMoreAccounts = () => this.loadMore('accounts'); // we need to resubmit the query for this specific type
handleLoadMoreStatuses = () => this.loadMore('statuses'); if (submittedType !== 'accounts') {
handleLoadMoreHashtags = () => this.loadMore('hashtags'); dispatch(submitSearch('accounts'));
}
this.setState({ type: 'accounts' });
};
handleSelectHashtags = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for something else (but not everything),
// we need to resubmit the query for this specific type
if (submittedType !== 'hashtags') {
dispatch(submitSearch('hashtags'));
}
loadMore (type) { this.setState({ type: 'hashtags' });
}
handleSelectStatuses = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for something else (but not everything),
// we need to resubmit the query for this specific type
if (submittedType !== 'statuses') {
dispatch(submitSearch('statuses'));
}
this.setState({ type: 'statuses' });
}
handleLoadMoreAccounts = () => this._loadMore('accounts');
handleLoadMoreStatuses = () => this._loadMore('statuses');
handleLoadMoreHashtags = () => this._loadMore('hashtags');
_loadMore (type) {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch(expandSearch(type)); dispatch(expandSearch(type));
} }
handleLoadMore = () => {
const { type } = this.state;
if (type !== 'all') {
this._loadMore(type);
}
};
render () { render () {
const { intl, isLoading, q, results } = this.props; const { intl, isLoading, q, results } = this.props;
const { type } = this.state; const { type } = this.state;
let filteredResults = ImmutableList(); // We request 1 more result than we display so we can tell if there'd be a next page
const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
let filteredResults;
if (!isLoading) { if (!isLoading) {
const accounts = results.get('accounts', ImmutableList());
const hashtags = results.get('hashtags', ImmutableList());
const statuses = results.get('statuses', ImmutableList());
switch(type) { switch(type) {
case 'all': case 'all':
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses)); filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
<>
{accounts.size > 0 && (
<SearchSection key='accounts' title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
{accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
</SearchSection>
)}
{hashtags.size > 0 && (
<SearchSection key='hashtags' title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
{hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</SearchSection>
)}
{statuses.size > 0 && (
<SearchSection key='statuses' title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
{statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
</SearchSection>
)}
</>
) : [];
break; break;
case 'accounts': case 'accounts':
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts)); filteredResults = renderAccounts(accounts);
break; break;
case 'hashtags': case 'hashtags':
filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags)); filteredResults = renderHashtags(hashtags);
break; break;
case 'statuses': case 'statuses':
filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses)); filteredResults = renderStatuses(statuses);
break; break;
} }
if (filteredResults.size === 0) {
filteredResults = (
<div className='empty-column-indicator'>
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
</div>
);
}
} }
return ( return (
@ -117,7 +207,16 @@ class Results extends PureComponent {
</div> </div>
<div className='explore__search-results'> <div className='explore__search-results'>
{isLoading ? <LoadingIndicator /> : filteredResults} <ScrollableList
scrollKey='search-results'
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
bindToDocument
>
{filteredResults}
</ScrollableList>
</div> </div>
<Helmet> <Helmet>

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

@ -100,8 +100,41 @@ class LoginForm extends React.PureComponent {
this.input = c; this.input = c;
}; };
isValueValid = (value) => {
let likelyAcct = false;
let url = null;
if (value.startsWith('/')) {
return false;
}
if (value.startsWith('@')) {
value = value.slice(1);
likelyAcct = true;
}
// The user is in the middle of typing something, do not error out
if (value === '') {
return true;
}
if (/^https?:\/\//.test(value) && !likelyAcct) {
url = value;
} else {
url = `https://${value}`;
}
try {
new URL(url);
return true;
} catch(_) {
return false;
}
};
handleChange = ({ target }) => { handleChange = ({ target }) => {
this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); const error = !this.isValueValid(target.value);
this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions());
}; };
handleMessage = (event) => { handleMessage = (event) => {
@ -115,11 +148,18 @@ class LoginForm extends React.PureComponent {
this.setState({ isSubmitting: false, error: true }); this.setState({ isSubmitting: false, error: true });
} else if (event.data?.type === 'fetchInteractionURL-success') { } else if (event.data?.type === 'fetchInteractionURL-success') {
if (/^https?:\/\//.test(event.data.template)) { if (/^https?:\/\//.test(event.data.template)) {
if (localStorage) { try {
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)));
}
window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)); if (localStorage) {
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
}
window.location.href = url;
} catch (e) {
console.error(e);
this.setState({ isSubmitting: false, error: true });
}
} else { } else {
this.setState({ isSubmitting: false, error: true }); this.setState({ isSubmitting: false, error: true });
} }
@ -259,7 +299,7 @@ class LoginForm extends React.PureComponent {
spellcheck='false' spellcheck='false'
/> />
<Button onClick={this.handleSubmit} disabled={isSubmitting}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button> <Button onClick={this.handleSubmit} disabled={isSubmitting || error}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button>
</div> </div>
{hasPopOut && ( {hasPopOut && (

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

@ -78,6 +78,7 @@ const PageThree = ({ myAccount }) => (
onSubmit={noop} onSubmit={noop}
onClear={noop} onClear={noop}
onShow={noop} onShow={noop}
recent={{}}
/> />
<div className='pseudo-drawer'> <div className='pseudo-drawer'>

@ -220,8 +220,9 @@ class Video extends PureComponent {
const { x } = getPointerPosition(this.volume, e); const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) { if(!isNaN(x)) {
this.setState({ volume: x }, () => { this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
this.video.volume = x; this.video.volume = x;
this.video.muted = this.state.muted;
}); });
} }
}, 15); }, 15);
@ -428,10 +429,11 @@ class Video extends PureComponent {
}; };
toggleMute = () => { toggleMute = () => {
const muted = !this.video.muted; const muted = !(this.video.muted || this.state.volume === 0);
this.setState({ muted }, () => { this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
this.video.muted = muted; this.video.volume = this.state.volume;
this.video.muted = this.state.muted;
}); });
}; };
@ -508,8 +510,10 @@ class Video extends PureComponent {
render () { render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props; const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100); const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
const playerStyle = {}; const playerStyle = {};
if (inline) { if (inline) {
@ -603,12 +607,12 @@ class Video extends PureComponent {
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} /> <div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%` }} />
<span <span
className={classNames('video-player__volume__handle')} className={classNames('video-player__volume__handle')}
tabIndex={0} tabIndex={0}
style={{ left: `${volume * 100}%` }} style={{ left: `${muted ? 0 : volume * 100}%` }}
/> />
</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');

@ -16,11 +16,17 @@
"advanced_options.local-only.long": "不要傳遞給其他實例", "advanced_options.local-only.long": "不要傳遞給其他實例",
"advanced_options.local-only.short": "僅限本地", "advanced_options.local-only.short": "僅限本地",
"advanced_options.local-only.tooltip": "此嘟文僅限本地", "advanced_options.local-only.tooltip": "此嘟文僅限本地",
"advanced_options.threaded_mode.long": "發佈時自動打開回覆",
"advanced_options.threaded_mode.short": "討論串模式", "advanced_options.threaded_mode.short": "討論串模式",
"advanced_options.threaded_mode.tooltip": "已啟用討論串模式", "advanced_options.threaded_mode.tooltip": "已啟用討論串模式",
"boost_modal.missing_description": "此嘟文包含未加說明的媒體檔案", "boost_modal.missing_description": "此嘟文包含未加說明的媒體檔案",
"column.favourited_by": "誰按了最愛",
"column.heading": "雜項",
"column.reblogged_by": "被誰轉嘟",
"column.subheading": "其他選項",
"column_header.profile": "個人檔案", "column_header.profile": "個人檔案",
"column_subheading.lists": "列表", "column_subheading.lists": "列表",
"column_subheading.navigation": "導覽",
"community.column_settings.allow_local_only": "顯示僅限本地的嘟文", "community.column_settings.allow_local_only": "顯示僅限本地的嘟文",
"compose.attach": "附加...", "compose.attach": "附加...",
"compose.attach.doodle": "塗鴉", "compose.attach.doodle": "塗鴉",
@ -30,27 +36,66 @@
"compose.content-type.plain": "純文字", "compose.content-type.plain": "純文字",
"compose_form.poll.multiple_choices": "允許多重選擇", "compose_form.poll.multiple_choices": "允許多重選擇",
"compose_form.poll.single_choice": "允許單一選擇", "compose_form.poll.single_choice": "允許單一選擇",
"compose_form.spoiler": "將文字隱藏在內容警告後面",
"confirmation_modal.do_not_ask_again": "不要再顯示確認訊息", "confirmation_modal.do_not_ask_again": "不要再顯示確認訊息",
"confirmations.deprecated_settings.confirm": "使用 Mastodon 偏好", "confirmations.deprecated_settings.confirm": "使用 Mastodon 偏好",
"confirmations.deprecated_settings.message": "您正在使用的某些特定於 glitch-soc 設備的 {app_settings} 已被 Mastodon {preferences} 所取代,並將被覆蓋:",
"confirmations.missing_media_description.confirm": "仍要張貼",
"confirmations.missing_media_description.edit": "編輯媒體", "confirmations.missing_media_description.edit": "編輯媒體",
"confirmations.missing_media_description.message": "至少有一個媒體附件缺少說明。 在發送嘟文之前,請考慮為視障人士在所有媒體附件加上說明。", "confirmations.missing_media_description.message": "至少有一個媒體附件缺少說明。 在發送嘟文之前,請考慮為視障人士在所有媒體附件加上說明。",
"confirmations.unfilter.author": "作者", "confirmations.unfilter.author": "作者",
"confirmations.unfilter.confirm": "顯示", "confirmations.unfilter.confirm": "顯示",
"confirmations.unfilter.edit_filter": "編輯篩選器",
"content-type.change": "內容類型", "content-type.change": "內容類型",
"direct.group_by_conversations": "以對話分組", "direct.group_by_conversations": "以對話分組",
"empty_column.follow_recommendations": "似乎未能為您產生任何建議。您可以嘗試使用搜尋來尋找您可能認識的人,或是探索熱門主題標籤。", "empty_column.follow_recommendations": "似乎未能為您產生任何建議。您可以嘗試使用搜尋來尋找您可能認識的人,或是探索熱門主題標籤。",
"endorsed_accounts_editor.endorsed_accounts": "受推薦帳號",
"favourite_modal.combo": "下次您可以按 {combo} 跳過",
"firehose.column_settings.allow_local_only": "在「全部」顯示僅限本地的貼文",
"follow_recommendations.done": "完成", "follow_recommendations.done": "完成",
"follow_recommendations.heading": "跟隨您想檢視其嘟文的人!這裡有一些建議。", "follow_recommendations.heading": "跟隨您想檢視其嘟文的人!這裡有一些建議。",
"follow_recommendations.lead": "來自您跟隨的人之嘟文將會按時間順序顯示在您的首頁時間軸上。不要害怕犯錯,您隨時都可以取消跟隨其他人!", "follow_recommendations.lead": "來自您跟隨的人之嘟文將會按時間順序顯示在您的首頁時間軸上。不要害怕犯錯,您隨時都可以取消跟隨其他人!",
"getting_started.onboarding": "帶我四處看看",
"home.column_settings.advanced": "進階設定", "home.column_settings.advanced": "進階設定",
"home.column_settings.filter_regex": "以正規表達式進行過濾", "home.column_settings.filter_regex": "以正規表達式進行過濾",
"home.column_settings.show_direct": "顯示私人提及",
"home.settings": "欄位設定",
"keyboard_shortcuts.bookmark": "到書籤",
"keyboard_shortcuts.secondary_toot": "使用次要隱私設定來發布嘟文",
"keyboard_shortcuts.toggle_collapse": "去折疊/展開嘟文",
"media_gallery.sensitive": "敏感", "media_gallery.sensitive": "敏感",
"moved_to_warning": "此帳戶已標記為移至 {moved_to_link},因此可能不接受新的追隨者。",
"navigation_bar.app_settings": "應用程式設定",
"navigation_bar.featured_users": "被推薦的使用者",
"navigation_bar.keyboard_shortcuts": "鍵盤快速鍵",
"navigation_bar.misc": "雜項",
"notification.markForDeletion": "標記刪除",
"notification_purge.btn_all": "選取全部", "notification_purge.btn_all": "選取全部",
"notification_purge.btn_apply": "清除所選項目", "notification_purge.btn_apply": "清除所選項目",
"notification_purge.btn_invert": "反向選擇", "notification_purge.btn_invert": "反向選擇",
"notification_purge.btn_none": "取消選取", "notification_purge.btn_none": "取消選取",
"notification_purge.start": "進入通知清理模式",
"notifications.marked_clear": "清除被選取的通知訊息",
"notifications.marked_clear_confirmation": "您確定要永久清除所有被選取的通知訊息嗎?",
"onboarding.done": "完成",
"onboarding.next": "下一個",
"onboarding.page_five.public_timelines": "本地時間軸顯示來自 {domain} 上所有人的公開貼文。聯合時間軸顯示 {domain} 上追隨的每個人發表的公開貼文。這些是公共時間軸,是發現新朋友的好方法。",
"onboarding.page_four.home": "首頁時間線會顯示你追隨的人發布的貼文。",
"onboarding.page_four.notifications": "當有人與您互動時會顯示在通知欄。",
"onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.handle": "你的帳號在 {domain} ,所以你的帳號全名是 {handle}",
"onboarding.page_one.welcome": "歡迎來到 {domain} ",
"onboarding.page_six.admin": "您的站台管理者是 {admin} 。",
"onboarding.page_six.almost_done": "就快完成了…",
"onboarding.page_six.apps_available": "有適用於 iOS、Android 和其他平台的 {apps}。",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "社群規範",
"onboarding.page_six.read_guidelines": "請閱讀 {domain} 的 {guidelines}",
"onboarding.page_six.various_app": "手機應用程式",
"onboarding.page_three.profile": "編輯您的個人資料以更改您的頭像、個人簡介和顯示名稱。在那裡,您還會發現其他偏好設置。",
"onboarding.page_three.search": "使用搜索欄查找他人與主題標籤,例如 {illustration} 和 {introductions} 。要尋找其他站台的人,請使用他們的完整帳號名稱。",
"onboarding.page_two.compose": "從撰寫欄撰寫帖子。您可以使用下面的圖示上傳圖片、更改隱私設置以及添加內容警告。",
"onboarding.skip": "略過",
"settings.always_show_spoilers_field": "永遠啟用內容警告欄位", "settings.always_show_spoilers_field": "永遠啟用內容警告欄位",
"settings.auto_collapse": "自動折疊", "settings.auto_collapse": "自動折疊",
"settings.auto_collapse_all": "全部", "settings.auto_collapse_all": "全部",
@ -83,19 +128,23 @@
"settings.hicolor_privacy_icons.hint": "用明亮且易於區分的顏色顯示隱私圖示", "settings.hicolor_privacy_icons.hint": "用明亮且易於區分的顏色顯示隱私圖示",
"settings.image_backgrounds": "圖片背景", "settings.image_backgrounds": "圖片背景",
"settings.image_backgrounds_media": "預覽折疊嘟文的媒體檔案", "settings.image_backgrounds_media": "預覽折疊嘟文的媒體檔案",
"settings.image_backgrounds_media_hint": "如果嘟文包含媒體檔案,使用一個作為圖片背景", "settings.image_backgrounds_media_hint": "如果嘟文包含媒體檔案,使用一個作為圖片背景",
"settings.image_backgrounds_users": "為折疊的嘟文加上圖片背景", "settings.image_backgrounds_users": "為折疊的嘟文加上圖片背景",
"settings.inline_preview_cards": "針對外部連接顯示內嵌的預覽卡",
"settings.layout_opts": "版面選項", "settings.layout_opts": "版面選項",
"settings.media": "媒體", "settings.media": "媒體",
"settings.media_fullwidth": "在媒體預覽中使用完整寬度", "settings.media_fullwidth": "在媒體預覽中使用完整寬度",
"settings.media_letterbox": "在媒體預覽加上黑邊", "settings.media_letterbox": "在媒體預覽加上黑邊",
"settings.media_letterbox_hint": "在媒體預覽中縮小並加上黑邊以取代延展與裁切", "settings.media_letterbox_hint": "在媒體預覽中縮小並加上黑邊以取代延展與裁切",
"settings.media_reveal_behind_cw": "預設顯示隱藏在內容警告的敏感媒體檔案", "settings.media_reveal_behind_cw": "預設顯示隱藏在內容警告的敏感媒體檔案",
"settings.notifications.favicon_badge": "未讀通知網站圖示徽章",
"settings.notifications.favicon_badge.hint": "在網站圖示上增加一個未讀通知徽章",
"settings.notifications.tab_badge": "未讀通知徽章", "settings.notifications.tab_badge": "未讀通知徽章",
"settings.notifications.tab_badge.hint": "當通知列未打開時,在導引圖示中顯示未讀通知的徽章", "settings.notifications.tab_badge.hint": "當通知列未打開時,在導引圖示中顯示未讀通知的徽章",
"settings.notifications_opts": "通知選項", "settings.notifications_opts": "通知選項",
"settings.pop_in_left": "左邊", "settings.pop_in_left": "左邊",
"settings.pop_in_player": "啟用彈出播放器", "settings.pop_in_player": "啟用彈出播放器",
"settings.pop_in_position": "彈出播放器位置:",
"settings.pop_in_right": "右邊", "settings.pop_in_right": "右邊",
"settings.preferences": "使用者偏好設定", "settings.preferences": "使用者偏好設定",
"settings.prepend_cw_re": "回覆時在內容警告前添加 \"re:\"", "settings.prepend_cw_re": "回覆時在內容警告前添加 \"re:\"",
@ -105,6 +154,7 @@
"settings.rewrite_mentions_acct": "改寫為使用者名稱與網域(當使用者來自外部)", "settings.rewrite_mentions_acct": "改寫為使用者名稱與網域(當使用者來自外部)",
"settings.rewrite_mentions_no": "不要改寫提及", "settings.rewrite_mentions_no": "不要改寫提及",
"settings.rewrite_mentions_username": "改寫為使用者名稱", "settings.rewrite_mentions_username": "改寫為使用者名稱",
"settings.shared_settings_link": "使用者偏好設定",
"settings.show_action_bar": "在折疊的嘟文顯示操作按鈕", "settings.show_action_bar": "在折疊的嘟文顯示操作按鈕",
"settings.show_content_type_choice": "在編寫嘟文時顯示內容類型選擇", "settings.show_content_type_choice": "在編寫嘟文時顯示內容類型選擇",
"settings.show_reply_counter": "顯示回覆數量的估計值", "settings.show_reply_counter": "顯示回覆數量的估計值",
@ -113,12 +163,14 @@
"settings.side_arm_reply_mode": "當回覆一篇嘟文時,次要發出嘟文按鈕應該設為:", "settings.side_arm_reply_mode": "當回覆一篇嘟文時,次要發出嘟文按鈕應該設為:",
"settings.side_arm_reply_mode.copy": "複製回覆嘟文的隱私設置", "settings.side_arm_reply_mode.copy": "複製回覆嘟文的隱私設置",
"settings.side_arm_reply_mode.keep": "保持原本的隱私設定", "settings.side_arm_reply_mode.keep": "保持原本的隱私設定",
"settings.side_arm_reply_mode.restrict": "限制只能使用與回覆嘟文相同的隱私設置",
"settings.status_icons": "嘟文圖示", "settings.status_icons": "嘟文圖示",
"settings.status_icons_language": "語言指示器", "settings.status_icons_language": "語言指示器",
"settings.status_icons_local_only": "僅限本地指示器", "settings.status_icons_local_only": "僅限本地指示器",
"settings.status_icons_media": "媒體與投票指示器", "settings.status_icons_media": "媒體與投票指示器",
"settings.status_icons_reply": "回覆指示器", "settings.status_icons_reply": "回覆指示器",
"settings.status_icons_visibility": "嘟文隱私指示器", "settings.status_icons_visibility": "嘟文隱私指示器",
"settings.swipe_to_change_columns": "允許使用滑動手勢更改顯示欄位(僅限移動裝置)",
"settings.tag_misleading_links": "標記誤導性的連結", "settings.tag_misleading_links": "標記誤導性的連結",
"settings.tag_misleading_links.hint": "在每個未明確提及的連結添加帶有連結目標主機的視覺指示", "settings.tag_misleading_links.hint": "在每個未明確提及的連結添加帶有連結目標主機的視覺指示",
"settings.wide_view": "寬廣模式(僅限桌面模式)", "settings.wide_view": "寬廣模式(僅限桌面模式)",
@ -130,5 +182,17 @@
"status.has_video": "包含視訊檔案", "status.has_video": "包含視訊檔案",
"status.in_reply_to": "嘟文有回覆", "status.in_reply_to": "嘟文有回覆",
"status.is_poll": "嘟文有投票", "status.is_poll": "嘟文有投票",
"status.local_only": "只在此實例可見" "status.local_only": "只在此實例可見",
"status.sensitive_toggle": "點擊查看",
"status.uncollapse": "展開",
"web_app_crash.change_your_settings": "修改你的 {settings}",
"web_app_crash.content": "您可以嘗試以下任一種方法:",
"web_app_crash.debug_info": "除錯資訊",
"web_app_crash.disable_addons": "禁用瀏覽器插件或內置翻譯工具",
"web_app_crash.issue_tracker": "問題追蹤系統",
"web_app_crash.reload": "重新載入",
"web_app_crash.reload_page": "{reload} 當前頁面",
"web_app_crash.report_issue": "到 {issuetracker} 回報問題",
"web_app_crash.settings": "設定",
"web_app_crash.title": "很抱歉Mastodon 應用程序出現問題。"
} }

@ -33,7 +33,7 @@ function main() {
console.error(err); console.error(err);
} }
if (registration) { if (registration && 'Notification' in window && Notification.permission === 'granted') {
const registerPushNotifications = await import('flavours/glitch/actions/push_notifications'); const registerPushNotifications = await import('flavours/glitch/actions/push_notifications');
store.dispatch(registerPushNotifications.register()); store.dispatch(registerPushNotifications.register());

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { import {
COMPOSE_MENTION, COMPOSE_MENTION,
@ -12,9 +12,9 @@ import {
SEARCH_FETCH_FAIL, SEARCH_FETCH_FAIL,
SEARCH_FETCH_SUCCESS, SEARCH_FETCH_SUCCESS,
SEARCH_SHOW, SEARCH_SHOW,
SEARCH_EXPAND_REQUEST,
SEARCH_EXPAND_SUCCESS, SEARCH_EXPAND_SUCCESS,
SEARCH_RESULT_CLICK, SEARCH_HISTORY_UPDATE,
SEARCH_RESULT_FORGET,
} from 'flavours/glitch/actions/search'; } from 'flavours/glitch/actions/search';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -24,6 +24,7 @@ const initialState = ImmutableMap({
results: ImmutableMap(), results: ImmutableMap(),
isLoading: false, isLoading: false,
searchTerm: '', searchTerm: '',
type: null,
recent: ImmutableOrderedSet(), recent: ImmutableOrderedSet(),
}); });
@ -37,6 +38,8 @@ export default function search(state = initialState, action) {
map.set('results', ImmutableMap()); map.set('results', ImmutableMap());
map.set('submitted', false); map.set('submitted', false);
map.set('hidden', false); map.set('hidden', false);
map.set('searchTerm', '');
map.set('type', null);
}); });
case SEARCH_SHOW: case SEARCH_SHOW:
return state.set('hidden', false); return state.set('hidden', false);
@ -48,27 +51,29 @@ export default function search(state = initialState, action) {
return state.withMutations(map => { return state.withMutations(map => {
map.set('isLoading', true); map.set('isLoading', true);
map.set('submitted', true); map.set('submitted', true);
map.set('type', action.searchType);
}); });
case SEARCH_FETCH_FAIL: case SEARCH_FETCH_FAIL:
return state.set('isLoading', false); return state.set('isLoading', false);
case SEARCH_FETCH_SUCCESS: case SEARCH_FETCH_SUCCESS:
return state.withMutations(map => { return state.withMutations(map => {
map.set('results', ImmutableMap({ map.set('results', ImmutableMap({
accounts: ImmutableList(action.results.accounts.map(item => item.id)), accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)), statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)),
hashtags: fromJS(action.results.hashtags), hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)),
})); }));
map.set('searchTerm', action.searchTerm); map.set('searchTerm', action.searchTerm);
map.set('type', action.searchType);
map.set('isLoading', false); map.set('isLoading', false);
}); });
case SEARCH_EXPAND_REQUEST:
return state.set('type', action.searchType);
case SEARCH_EXPAND_SUCCESS: case SEARCH_EXPAND_SUCCESS:
const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id); const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id);
return state.updateIn(['results', action.searchType], list => list.concat(results)); return state.updateIn(['results', action.searchType], list => list.union(results));
case SEARCH_RESULT_CLICK: case SEARCH_HISTORY_UPDATE:
return state.update('recent', set => set.add(fromJS(action.result))); return state.set('recent', ImmutableOrderedSet(fromJS(action.recent)));
case SEARCH_RESULT_FORGET:
return state.update('recent', set => set.filterNot(result => result.get('q') === action.q));
default: default:
return state; return state;
} }

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

@ -46,3 +46,4 @@ export default class Settings {
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data'); export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
export const tagHistory = new Settings('mastodon_tag_history'); export const tagHistory = new Settings('mastodon_tag_history');
export const bannerSettings = new Settings('mastodon_banner_settings'); export const bannerSettings = new Settings('mastodon_banner_settings');
export const searchHistory = new Settings('mastodon_search_history');

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

@ -365,7 +365,7 @@
flex-shrink: 0; flex-shrink: 0;
button { button {
background: darken($ui-base-color, 4%); background: transparent;
border: 0; border: 0;
margin: 0; margin: 0;
} }
@ -383,26 +383,18 @@
position: relative; position: relative;
&.active { &.active {
color: $secondary-text-color; color: $primary-text-color;
&::before, &::before {
&::after {
display: block; display: block;
content: ''; content: '';
position: absolute; position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 0;
transform: translateX(-50%);
border-style: solid;
border-width: 0 10px 10px;
border-color: transparent transparent lighten($ui-base-color, 8%);
}
&::after {
bottom: -1px; bottom: -1px;
border-color: transparent transparent $ui-base-color; left: 0;
width: 100%;
height: 3px;
border-radius: 4px;
background: $highlight-text-color;
} }
} }
} }

@ -228,6 +228,22 @@ $ui-header-height: 55px;
top: -48px; top: -48px;
} }
.switch-to-advanced {
color: $light-text-color;
background-color: $ui-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,14 +977,14 @@ $ui-header-height: 55px;
} }
} }
.dismissable-banner { .dismissable-banner,
.warning-banner {
position: relative; position: relative;
margin: 10px; margin: 10px;
margin-bottom: 5px; margin-bottom: 5px;
border-radius: 8px; border-radius: 8px;
border: 1px solid $highlight-text-color; border: 1px solid $highlight-text-color;
background: rgba($highlight-text-color, 0.15); background: rgba($highlight-text-color, 0.15);
padding-inline-end: 45px;
overflow: hidden; overflow: hidden;
&__background-image { &__background-image {
@ -1028,10 +1044,8 @@ $ui-header-height: 55px;
} }
&__action { &__action {
position: absolute; float: right;
inset-inline-end: 0; padding: 15px 10px;
top: 0;
padding: 10px;
.icon-button { .icon-button {
color: $highlight-text-color; color: $highlight-text-color;
@ -1039,6 +1053,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;

@ -132,22 +132,39 @@
} }
.search-results__section { .search-results__section {
margin-bottom: 5px; border-bottom: 1px solid lighten($ui-base-color, 8%);
h5 { &:last-child {
border-bottom: 0;
}
&__header {
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: default;
display: flex;
padding: 15px; padding: 15px;
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 14px;
color: $dark-text-color; color: $darker-text-color;
display: flex;
justify-content: space-between;
.fa { h3 .fa {
display: inline-block;
margin-inline-end: 5px; margin-inline-end: 5px;
} }
button {
color: $highlight-text-color;
padding: 0;
border: 0;
background: 0;
font: inherit;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
}
} }
.account:last-child, .account:last-child,

@ -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;
@ -72,6 +78,11 @@
font-weight: 700; font-weight: 700;
color: $primary-text-color; color: $primary-text-color;
} }
span {
overflow: inherit;
text-overflow: inherit;
}
} }
} }
} }

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

@ -461,8 +461,8 @@
&.status-direct > .status__content::after { &.status-direct > .status__content::after {
background: linear-gradient( background: linear-gradient(
rgba(lighten($ui-base-color, 8%), 0), rgba(mix($ui-base-color, $ui-highlight-color, 95%), 0),
rgba(lighten($ui-base-color, 8%), 1) rgba(mix($ui-base-color, $ui-highlight-color, 95%), 1)
); );
} }

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

@ -113,4 +113,11 @@ body.rtl {
.fa-chevron-right::before { .fa-chevron-right::before {
content: '\F053'; content: '\F053';
} }
.dismissable-banner,
.warning-banner {
&__action {
float: left;
}
}
} }

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

@ -1,37 +0,0 @@
import api from '../api';
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export function submitAccountNote(id, value) {
return (dispatch, getState) => {
dispatch(submitAccountNoteRequest());
api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: value,
}).then(response => {
dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error)));
};
}
export function submitAccountNoteRequest() {
return {
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
};
}
export function submitAccountNoteSuccess(relationship) {
return {
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
relationship,
};
}
export function submitAccountNoteFail(error) {
return {
type: ACCOUNT_NOTE_SUBMIT_FAIL,
error,
};
}

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
import api from '../api';
export const submitAccountNote = createAppAsyncThunk(
'account_note/submit',
async (args: { id: string; value: string }, { getState }) => {
// TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged
const response = await api(getState).post<unknown>(
`/api/v1/accounts/${args.id}/note`,
{
comment: args.value,
},
);
return { relationship: response.data };
},
);

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

@ -18,6 +18,7 @@ import {
importFetchedStatuses, importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
@ -293,6 +294,10 @@ export function requestBrowserPermission(callback = noOp) {
requestNotificationPermission((permission) => { requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission)); dispatch(setBrowserPermission(permission));
callback(permission); callback(permission);
if (permission === 'granted') {
dispatch(registerPushNotifications());
}
}); });
}; };
} }

@ -1,3 +1,7 @@
import { fromJS } from 'immutable';
import { searchHistory } from 'mastodon/settings';
import api from '../api'; import api from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
@ -15,8 +19,7 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK'; export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
export function changeSearch(value) { export function changeSearch(value) {
return { return {
@ -37,17 +40,17 @@ export function submitSearch(type) {
const signedIn = !!getState().getIn(['meta', 'me']); const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) { if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
return; return;
} }
dispatch(fetchSearchRequest()); dispatch(fetchSearchRequest(type));
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params: { params: {
q: value, q: value,
resolve: signedIn, resolve: signedIn,
limit: 5, limit: 11,
type, type,
}, },
}).then(response => { }).then(response => {
@ -59,7 +62,7 @@ export function submitSearch(type) {
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(response.data.statuses));
} }
dispatch(fetchSearchSuccess(response.data, value)); dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchSearchFail(error)); dispatch(fetchSearchFail(error));
@ -67,16 +70,18 @@ export function submitSearch(type) {
}; };
} }
export function fetchSearchRequest() { export function fetchSearchRequest(searchType) {
return { return {
type: SEARCH_FETCH_REQUEST, type: SEARCH_FETCH_REQUEST,
searchType,
}; };
} }
export function fetchSearchSuccess(results, searchTerm) { export function fetchSearchSuccess(results, searchTerm, searchType) {
return { return {
type: SEARCH_FETCH_SUCCESS, type: SEARCH_FETCH_SUCCESS,
results, results,
searchType,
searchTerm, searchTerm,
}; };
} }
@ -90,15 +95,16 @@ export function fetchSearchFail(error) {
export const expandSearch = type => (dispatch, getState) => { export const expandSearch = type => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']); const value = getState().getIn(['search', 'value']);
const offset = getState().getIn(['search', 'results', type]).size; const offset = getState().getIn(['search', 'results', type]).size - 1;
dispatch(expandSearchRequest()); dispatch(expandSearchRequest(type));
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params: { params: {
q: value, q: value,
type, type,
offset, offset,
limit: 11,
}, },
}).then(({ data }) => { }).then(({ data }) => {
if (data.accounts) { if (data.accounts) {
@ -116,8 +122,9 @@ export const expandSearch = type => (dispatch, getState) => {
}); });
}; };
export const expandSearchRequest = () => ({ export const expandSearchRequest = (searchType) => ({
type: SEARCH_EXPAND_REQUEST, type: SEARCH_EXPAND_REQUEST,
searchType,
}); });
export const expandSearchSuccess = (results, searchTerm, searchType) => ({ export const expandSearchSuccess = (results, searchTerm, searchType) => ({
@ -166,16 +173,34 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => {
}); });
}; };
export const clickSearchResult = (q, type) => ({ export const clickSearchResult = (q, type) => (dispatch, getState) => {
type: SEARCH_RESULT_CLICK, const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
result: { searchHistory.set(me, current.toJS());
type, dispatch(updateSearchHistory(current));
q, };
},
}); export const forgetSearchResult = q => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.filterNot(result => result.get('q') === q);
export const forgetSearchResult = q => ({ searchHistory.set(me, current.toJS());
type: SEARCH_RESULT_FORGET, dispatch(updateSearchHistory(current));
q, };
export const updateSearchHistory = recent => ({
type: SEARCH_HISTORY_UPDATE,
recent,
}); });
export const hydrateSearch = () => (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = searchHistory.get(me);
if (history !== null) {
dispatch(updateSearchHistory(history));
}
};

@ -2,6 +2,7 @@ import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose'; import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { hydrateSearch } from './search';
export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -20,6 +21,7 @@ export function hydrateStore(rawState) {
}); });
dispatch(hydrateCompose()); dispatch(hydrateCompose());
dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts))); dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
}; };
} }

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

Loading…
Cancel
Save