Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
d9ea6abec0
785 changed files with 17127 additions and 19813 deletions
56
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
56
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
|
@ -1,56 +0,0 @@
|
|||
name: Bug Report
|
||||
description: If something isn't working as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Make sure that you are submitting a new bug that was not previously reported or already fixed.
|
||||
|
||||
Please use a concise and distinct title for the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce the problem
|
||||
description: What were you trying to do?
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Actual behaviour
|
||||
description: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Detailed description
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Specifications
|
||||
description: |
|
||||
What version or commit hash of Mastodon did you find this bug in?
|
||||
|
||||
If a front-end issue, what browser and operating systems were you using?
|
||||
placeholder: |
|
||||
Mastodon 3.5.3 (or Edge)
|
||||
Ruby 2.7.6 (or v3.1.2)
|
||||
Node.js 16.18.0
|
||||
|
||||
Google Chrome 106.0.5249.119
|
||||
Firefox 105.0.3
|
||||
|
||||
etc...
|
||||
validations:
|
||||
required: true
|
76
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
name: Bug Report (Web Interface)
|
||||
description: If you are using Mastodon's web interface and something is not working as expected
|
||||
labels: [bug, 'status/to triage', 'area/web interface']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Make sure that you are submitting a new bug that was not previously reported or already fixed.
|
||||
|
||||
Please use a concise and distinct title for the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce the problem
|
||||
description: What were you trying to do?
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Actual behaviour
|
||||
description: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Detailed description
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Mastodon instance
|
||||
description: The address of the Mastodon instance where you experienced the issue
|
||||
placeholder: mastodon.social
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627`
|
||||
placeholder: v4.1.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser name and version
|
||||
description: |
|
||||
What browser are you using when getting this bug? Please specify the version as well.
|
||||
placeholder: Firefox 105.0.3
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: |
|
||||
What OS are you running? Please specify the version as well.
|
||||
placeholder: macOS 13.4.1
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Technical details
|
||||
description: |
|
||||
Any additional technical details you may have. This can include the full error log, inspector's output…
|
||||
validations:
|
||||
required: false
|
65
.github/ISSUE_TEMPLATE/2.server_bug_report.yml
vendored
Normal file
65
.github/ISSUE_TEMPLATE/2.server_bug_report.yml
vendored
Normal file
|
@ -0,0 +1,65 @@
|
|||
name: Bug Report (server / API)
|
||||
description: |
|
||||
If something is not working as expected, but is not from using the web interface.
|
||||
labels: [bug, 'status/to triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Make sure that you are submitting a new bug that was not previously reported or already fixed.
|
||||
|
||||
Please use a concise and distinct title for the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce the problem
|
||||
description: What were you trying to do?
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Actual behaviour
|
||||
description: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Detailed description
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Mastodon instance
|
||||
description: The address of the Mastodon instance where you experienced the issue
|
||||
placeholder: mastodon.social
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627`
|
||||
placeholder: v4.1.2
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Technical details
|
||||
description: |
|
||||
Any additional technical details you may have, like logs or error traces
|
||||
value: |
|
||||
If this is happening on your own Mastodon server, please fill out those:
|
||||
- Ruby version: (from `ruby --version`, eg. v3.1.2)
|
||||
- Node.js version: (from `node --version`, eg. v18.16.0)
|
||||
validations:
|
||||
required: false
|
24
.github/renovate.json5
vendored
24
.github/renovate.json5
vendored
|
@ -9,7 +9,9 @@
|
|||
],
|
||||
stabilityDays: 3, // Wait 3 days after the package has been published before upgrading it
|
||||
// packageRules order is important, they are applied from top to bottom and are merged,
|
||||
// so for example grouping rules needs to be at the bottom
|
||||
// meaning the most important ones must be at the bottom, for example grouping rules
|
||||
// If we do not want a package to be grouped with others, we need to set its groupName
|
||||
// to `null` after any other rule set it to something.
|
||||
packageRules: [
|
||||
{
|
||||
// Ignore major version bumps for these node packages
|
||||
|
@ -45,6 +47,7 @@
|
|||
// Ignore major version bumps for these Ruby packages
|
||||
matchManagers: ['bundler'],
|
||||
matchPackageNames: [
|
||||
'rack', // Needs to be synced with Rails version
|
||||
'sprockets', // Requires manual upgrade https://github.com/rails/sprockets/blob/master/UPGRADING.md#guide-to-upgrading-from-sprockets-3x-to-4x
|
||||
'strong_migrations', // Requires manual upgrade
|
||||
'sidekiq', // Requires manual upgrade
|
||||
|
@ -84,12 +87,17 @@
|
|||
// Update devDependencies every week, with one grouped PR
|
||||
matchDepTypes: 'devDependencies',
|
||||
matchUpdateTypes: ['patch', 'minor'],
|
||||
excludePackageNames: [
|
||||
'typescript', // Typescript has many changes in minor versions, needs to be checked every time
|
||||
],
|
||||
groupName: 'devDependencies (non-major)',
|
||||
extends: ['schedule:weekly'],
|
||||
},
|
||||
{
|
||||
// Group all eslint-related packages with `eslint` in the same PR
|
||||
matchManagers: ['npm'],
|
||||
matchPackageNames: ['eslint'],
|
||||
matchPackagePrefixes: ['eslint-', '@typescript-eslint/'],
|
||||
matchUpdateTypes: ['patch', 'minor'],
|
||||
groupName: 'eslint (non-major)',
|
||||
},
|
||||
{
|
||||
// Update @types/* packages every week, with one grouped PR
|
||||
matchPackagePrefixes: '@types/',
|
||||
|
@ -98,6 +106,14 @@
|
|||
extends: ['schedule:weekly'],
|
||||
addLabels: ['typescript'],
|
||||
},
|
||||
{
|
||||
// We want those packages to always have their own PR
|
||||
matchManagers: ['npm'],
|
||||
matchPackageNames: [
|
||||
'typescript', // Typescript has code-impacting changes in minor versions
|
||||
],
|
||||
groupName: null, // We dont want them to belong to any group
|
||||
},
|
||||
// Add labels depending on package manager
|
||||
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
||||
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
||||
|
|
94
.github/workflows/build-container-image.yml
vendored
Normal file
94
.github/workflows/build-container-image.yml
vendored
Normal file
|
@ -0,0 +1,94 @@
|
|||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platforms:
|
||||
required: true
|
||||
type: string
|
||||
use_native_arm64_builder:
|
||||
type: boolean
|
||||
push_to_images:
|
||||
type: string
|
||||
version_suffix:
|
||||
type: string
|
||||
flavor:
|
||||
type: string
|
||||
tags:
|
||||
type: string
|
||||
labels:
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder
|
||||
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
id: buildx
|
||||
if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }}
|
||||
|
||||
- name: Start a local Docker Builder
|
||||
if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')
|
||||
run: |
|
||||
docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234
|
||||
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
id: buildx-native
|
||||
if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')
|
||||
with:
|
||||
driver: remote
|
||||
endpoint: tcp://localhost:1234
|
||||
platforms: linux/amd64
|
||||
append: |
|
||||
- endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865
|
||||
platforms: linux/arm64
|
||||
name: mastodon-docker-builder-arm64-01
|
||||
driver-opts:
|
||||
- servername=mastodon-docker-builder-arm64-01
|
||||
env:
|
||||
BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }}
|
||||
BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }}
|
||||
BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: contains(inputs.push_to_images, 'tootsuite')
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
if: contains(inputs.push_to_images, 'ghcr.io')
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v4
|
||||
id: meta
|
||||
if: ${{ inputs.push_to_images != '' }}
|
||||
with:
|
||||
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 }}
|
||||
tags: ${{ inputs.tags }}
|
||||
labels: ${{ inputs.labels }}
|
||||
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
build-args: MASTODON_VERSION_SUFFIX=${{ inputs.version_suffix }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}
|
||||
push: ${{ inputs.push_to_images != '' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
63
.github/workflows/build-image.yml
vendored
63
.github/workflows/build-image.yml
vendored
|
@ -1,63 +0,0 @@
|
|||
name: Build container image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-image.yml
|
||||
- Dockerfile
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: hadolint/hadolint-action@v3.1.0
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- uses: docker/metadata-action@v4
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=edge,branch=main
|
||||
type=sha,prefix=,format=long
|
||||
|
||||
- name: Generate version suffix
|
||||
id: version_vars
|
||||
if: github.repository == 'mastodon/mastodon' && github.event_name == 'push' && github.ref_name == 'main'
|
||||
run: |
|
||||
echo mastodon_version_suffix=+edge-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
73
.github/workflows/build-nightly.yml
vendored
73
.github/workflows/build-nightly.yml
vendored
|
@ -3,58 +3,39 @@ on:
|
|||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # run at 2 AM UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-nightly-image:
|
||||
compute-suffix:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: hadolint/hadolint-action@v3.1.0
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v4
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/mastodon/mastodon
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=raw,value=nightly
|
||||
type=schedule,pattern=nightly-{{date 'YYYY-MM-DD' tz='Etc/UTC'}}
|
||||
labels: |
|
||||
org.opencontainers.image.description=Nightly build image used for testing purposes
|
||||
|
||||
- name: Generate version suffix
|
||||
id: version_vars
|
||||
- id: version_vars
|
||||
env:
|
||||
TZ: Etc/UTC
|
||||
run: |
|
||||
echo mastodon_version_suffix=+nightly-$(date +'%Y%m%d') >> $GITHUB_OUTPUT
|
||||
echo mastodon_version_suffix=nightly-$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
use_native_arm64_builder: false
|
||||
push_to_images: |
|
||||
ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
# The `+` is important here, result will be v4.1.2+nightly-2022-03-05
|
||||
version_suffix: +${{ needs.compute-suffix.outputs.suffix }}
|
||||
labels: |
|
||||
org.opencontainers.image.description=Nightly build image used for testing purposes
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=raw,value=nightly
|
||||
type=schedule,pattern=${{ needs.compute-suffix.outputs.suffix }}
|
||||
secrets: inherit
|
||||
|
|
41
.github/workflows/build-push-pr.yml
vendored
Normal file
41
.github/workflows/build-push-pr.yml
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
name: Build container image for PR
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, synchronize, reopened, ready_for_review, opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
compute-suffix:
|
||||
runs-on: ubuntu-latest
|
||||
# This is only allowed to run if:
|
||||
# - the PR branch is in the `mastodon/mastodon` repository
|
||||
# - the PR is not a draft
|
||||
# - the PR has the "build-image" label
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'build-image') }}
|
||||
steps:
|
||||
# Repository needs to be cloned so `git rev-parse` below works
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v3
|
||||
- id: version_vars
|
||||
run: |
|
||||
echo mastodon_version_suffix=+pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
use_native_arm64_builder: false
|
||||
push_to_images: |
|
||||
ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
version_suffix: ${{ needs.compute-suffix.outputs.suffix }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
secrets: inherit
|
24
.github/workflows/build-releases.yml
vendored
Normal file
24
.github/workflows/build-releases.yml
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
name: Build container release images
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
use_native_arm64_builder: false
|
||||
push_to_images: |
|
||||
ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
secrets: inherit
|
77
.github/workflows/crowdin-download.yml
vendored
Normal file
77
.github/workflows/crowdin-download.yml
vendored
Normal file
|
@ -0,0 +1,77 @@
|
|||
name: Crowdin / Download translations
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * *' # Every day
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
download-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
# See https://github.com/orgs/community/discussions/55820
|
||||
run: |
|
||||
git config --global http.version HTTP/1.1
|
||||
git config --global http.postBuffer 157286400
|
||||
|
||||
# Download the translation files from Crowdin
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
config: crowdin-glitch.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
crowdin_branch_name: main
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# As the files are extracted from a Docker container, they belong to root:root
|
||||
# We need to fix this before the next steps
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# This is needed to run the normalize step
|
||||
- name: Install native Ruby dependencies
|
||||
run: sudo apt-get install -y libicu-dev libidn11-dev
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run i18n normalize task
|
||||
run: bundle exec i18n-tasks normalize
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5.0.2
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations (automated)'
|
||||
author: 'GitHub Actions <noreply@github.com>'
|
||||
body: |
|
||||
New Crowdin translations, automated with Github Actions
|
||||
|
||||
See `.github/workflows/crowdin-download.yml`
|
||||
|
||||
This PR will be updated every day with new translations.
|
||||
|
||||
Due to a limitation in Github Actions, checks are not running on this PR without manual action.
|
||||
If you want to run the checks, then close and re-open it.
|
||||
branch: i18n/crowdin/translations
|
||||
base: main
|
||||
labels: i18n
|
36
.github/workflows/crowdin-upload.yml
vendored
Normal file
36
.github/workflows/crowdin-upload.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
name: Crowdin / Upload translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- crowdin.yml
|
||||
- app/javascript/mastodon/locales/en.json
|
||||
- config/locales/en.yml
|
||||
- config/locales/simple_form.en.yml
|
||||
- config/locales/activerecord.en.yml
|
||||
- config/locales/devise.en.yml
|
||||
- config/locales/doorkeeper.en.yml
|
||||
- .github/workflows/crowdin-upload.yml
|
||||
|
||||
jobs:
|
||||
upload-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
config: crowdin-glitch.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
crowdin_branch_name: main
|
||||
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
2
.github/workflows/rebase-needed.yml
vendored
2
.github/workflows/rebase-needed.yml
vendored
|
@ -23,5 +23,5 @@ jobs:
|
|||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
commentOnClean: This pull request has resolved merge conflicts and is ready for review.
|
||||
commentOnDirty: This pull request has merge conflicts that must be resolved before it can be merged.
|
||||
retryMax: 10
|
||||
retryMax: 30
|
||||
continueOnMissingPermissions: false
|
||||
|
|
21
.github/workflows/test-image-build.yml
vendored
Normal file
21
.github/workflows/test-image-build.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Test container image build
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-nightly.yml
|
||||
- .github/workflows/build-push-pr.yml
|
||||
- .github/workflows/build-releases.yml
|
||||
- .github/workflows/test-image-build.yml
|
||||
- Dockerfile
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
platforms: linux/amd64 # Testing only on native platform so it is performant
|
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
|
@ -107,6 +107,10 @@ jobs:
|
|||
PAM_ENABLED: true
|
||||
PAM_DEFAULT_SERVICE: pam_test
|
||||
PAM_CONTROLLED_SERVICE: pam_test_controlled
|
||||
OIDC_ENABLED: true
|
||||
OIDC_SCOPE: read
|
||||
SAML_ENABLED: true
|
||||
CAS_ENABLED: true
|
||||
BUNDLE_WITH: 'pam_authentication test'
|
||||
CI_JOBS: ${{ matrix.ci_job }}/4
|
||||
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
# This configuration was generated by
|
||||
# `haml-lint --auto-gen-config`
|
||||
# on 2023-07-11 23:58:05 +0200 using Haml-Lint version 0.48.0.
|
||||
# on 2023-07-20 09:47:50 -0400 using Haml-Lint version 0.48.0.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the lints are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
# versions of Haml-Lint, may require this file to be generated again.
|
||||
|
||||
linters:
|
||||
# Offense count: 94
|
||||
RuboCop:
|
||||
enabled: false
|
||||
|
||||
# Offense count: 960
|
||||
# Offense count: 951
|
||||
LineLength:
|
||||
enabled: false
|
||||
|
||||
|
@ -19,6 +15,10 @@ linters:
|
|||
UnnecessaryStringOutput:
|
||||
enabled: false
|
||||
|
||||
# Offense count: 57
|
||||
RuboCop:
|
||||
enabled: false
|
||||
|
||||
# Offense count: 3
|
||||
ViewLength:
|
||||
exclude:
|
||||
|
@ -26,27 +26,18 @@ linters:
|
|||
- 'app/views/admin/reports/show.html.haml'
|
||||
- 'app/views/disputes/strikes/show.html.haml'
|
||||
|
||||
# Offense count: 41
|
||||
# Offense count: 32
|
||||
InstanceVariables:
|
||||
exclude:
|
||||
- 'app/views/admin/reports/_actions.html.haml'
|
||||
- 'app/views/admin/roles/_form.html.haml'
|
||||
- 'app/views/admin/webhooks/_form.html.haml'
|
||||
- 'app/views/auth/registrations/_sessions.html.haml'
|
||||
- 'app/views/auth/registrations/_status.html.haml'
|
||||
- 'app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml'
|
||||
- 'app/views/authorize_interactions/_post_follow_actions.html.haml'
|
||||
- 'app/views/invites/_form.html.haml'
|
||||
- 'app/views/relationships/_account.html.haml'
|
||||
- 'app/views/shared/_og.html.haml'
|
||||
- 'app/views/statuses/_status.html.haml'
|
||||
|
||||
# Offense count: 6
|
||||
ConsecutiveSilentScripts:
|
||||
exclude:
|
||||
- 'app/views/admin/settings/shared/_links.html.haml'
|
||||
- 'app/views/settings/login_activities/_login_activity.html.haml'
|
||||
- 'app/views/statuses/_poll.html.haml'
|
||||
|
||||
# Offense count: 3
|
||||
IdNames:
|
||||
|
|
|
@ -131,12 +131,6 @@ RSpec/FilePath:
|
|||
Exclude:
|
||||
- 'spec/config/initializers/rack_attack_spec.rb' # namespaces usually have separate folder
|
||||
- 'spec/lib/sanitize_config_spec.rb' # namespaces usually have separate folder
|
||||
- 'spec/controllers/concerns/account_controller_concern_spec.rb' # Concerns describe ApplicationController and don't fit naming
|
||||
- 'spec/controllers/concerns/export_controller_concern_spec.rb'
|
||||
- 'spec/controllers/concerns/localized_spec.rb'
|
||||
- 'spec/controllers/concerns/rate_limit_headers_spec.rb'
|
||||
- 'spec/controllers/concerns/signature_verification_spec.rb'
|
||||
- 'spec/controllers/concerns/user_tracking_concern_spec.rb'
|
||||
|
||||
# Reason:
|
||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.54.1.
|
||||
# using RuboCop version 1.54.2.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -127,12 +127,6 @@ Lint/UselessAssignment:
|
|||
- 'spec/services/resolve_url_service_spec.rb'
|
||||
- 'spec/views/statuses/show.html.haml_spec.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: CheckForMethodsWithNoSideEffects.
|
||||
Lint/Void:
|
||||
Exclude:
|
||||
- 'spec/services/resolve_account_service_spec.rb'
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||
Metrics/AbcSize:
|
||||
Max: 150
|
||||
|
@ -152,13 +146,6 @@ Metrics/CyclomaticComplexity:
|
|||
Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
|
||||
# Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms.
|
||||
# CheckDefinitionPathHierarchyRoots: lib, spec, test, src
|
||||
# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS
|
||||
Naming/FileName:
|
||||
Exclude:
|
||||
- 'config/locales/sr-Latn.rb'
|
||||
|
||||
# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
|
||||
# SupportedStyles: snake_case, normalcase, non_integer
|
||||
# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64
|
||||
|
@ -196,7 +183,6 @@ RSpec/AnyInstance:
|
|||
- 'spec/models/account_spec.rb'
|
||||
- 'spec/models/setting_spec.rb'
|
||||
- 'spec/services/activitypub/process_collection_service_spec.rb'
|
||||
- 'spec/validators/blacklisted_email_validator_spec.rb'
|
||||
- 'spec/validators/follow_limit_validator_spec.rb'
|
||||
- 'spec/workers/activitypub/delivery_worker_spec.rb'
|
||||
- 'spec/workers/web/push_notification_worker_spec.rb'
|
||||
|
@ -288,7 +274,6 @@ RSpec/LetSetup:
|
|||
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
|
||||
- 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
|
||||
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
|
||||
- 'spec/controllers/api/v2/filters_controller_spec.rb'
|
||||
- 'spec/controllers/auth/confirmations_controller_spec.rb'
|
||||
- 'spec/controllers/auth/passwords_controller_spec.rb'
|
||||
- 'spec/controllers/auth/sessions_controller_spec.rb'
|
||||
|
@ -298,6 +283,7 @@ RSpec/LetSetup:
|
|||
- 'spec/controllers/oauth/tokens_controller_spec.rb'
|
||||
- 'spec/controllers/settings/imports_controller_spec.rb'
|
||||
- 'spec/lib/activitypub/activity/delete_spec.rb'
|
||||
- 'spec/lib/vacuum/applications_vacuum_spec.rb'
|
||||
- 'spec/lib/vacuum/preview_cards_vacuum_spec.rb'
|
||||
- 'spec/models/account_spec.rb'
|
||||
- 'spec/models/account_statuses_cleanup_policy_spec.rb'
|
||||
|
@ -335,11 +321,7 @@ RSpec/MessageChain:
|
|||
RSpec/MessageSpies:
|
||||
Exclude:
|
||||
- 'spec/controllers/admin/accounts_controller_spec.rb'
|
||||
- 'spec/controllers/api/base_controller_spec.rb'
|
||||
- 'spec/controllers/auth/registrations_controller_spec.rb'
|
||||
- 'spec/helpers/admin/account_moderation_notes_helper_spec.rb'
|
||||
- 'spec/helpers/application_helper_spec.rb'
|
||||
- 'spec/lib/status_finder_spec.rb'
|
||||
- 'spec/lib/webfinger_resource_spec.rb'
|
||||
- 'spec/models/admin/account_action_spec.rb'
|
||||
- 'spec/models/concerns/remotable_spec.rb'
|
||||
|
@ -375,7 +357,7 @@ Rails/ApplicationController:
|
|||
|
||||
# Configuration parameters: Database, Include.
|
||||
# SupportedDatabases: mysql, postgresql
|
||||
# Include: db/migrate/*.rb
|
||||
# Include: db/**/*.rb
|
||||
Rails/BulkChangeTable:
|
||||
Exclude:
|
||||
- 'db/migrate/20160222143943_add_profile_fields_to_accounts.rb'
|
||||
|
@ -411,7 +393,7 @@ Rails/BulkChangeTable:
|
|||
- 'db/migrate/20220824164433_add_human_identifier_to_admin_action_logs.rb'
|
||||
|
||||
# Configuration parameters: Include.
|
||||
# Include: db/migrate/*.rb
|
||||
# Include: db/**/*.rb
|
||||
Rails/CreateTableWithTimestamps:
|
||||
Exclude:
|
||||
- 'db/migrate/20170508230434_create_conversation_mutes.rb'
|
||||
|
@ -806,7 +788,6 @@ Style/MutableConstant:
|
|||
Exclude:
|
||||
- 'app/models/tag.rb'
|
||||
- 'app/services/delete_account_service.rb'
|
||||
- 'config/initializers/twitter_regex.rb'
|
||||
- 'lib/mastodon/migration_warning.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
|
@ -848,8 +829,6 @@ Style/RedundantConstantBase:
|
|||
Exclude:
|
||||
- 'config/environments/production.rb'
|
||||
- 'config/initializers/sidekiq.rb'
|
||||
- 'config/locales/sr-Latn.rb'
|
||||
- 'config/locales/sr.rb'
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
# Configuration parameters: SafeForConstants.
|
||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -2,6 +2,26 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.1.5] - 2023-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Add check preventing Sidekiq workers from running with Makara configured ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25850))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change request timeout handling to use a longer deadline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26055))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix moderation interface for remote instances with a .zip TLD ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25885))
|
||||
- Fix remote accounts being possibly persisted to database with incomplete protocol values ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886))
|
||||
- Fix trending publishers table not rendering correctly on narrow screens ([vmstan](https://github.com/mastodon/mastodon/pull/25945))
|
||||
|
||||
### Security
|
||||
|
||||
- Fix CSP headers being unintentionally wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26105))
|
||||
|
||||
## [4.1.4] - 2023-07-07
|
||||
|
||||
### Fixed
|
||||
|
|
1
Gemfile
1
Gemfile
|
@ -18,6 +18,7 @@ gem 'aws-sdk-s3', '~> 1.123', require: false
|
|||
gem 'fog-core', '<= 2.4.0'
|
||||
gem 'fog-openstack', '~> 0.3', require: false
|
||||
gem 'kt-paperclip', '~> 7.2'
|
||||
gem 'md-paperclip-azure', '~> 2.2', require: false
|
||||
gem 'blurhash', '~> 0.1'
|
||||
|
||||
gem 'active_model_serializers', '~> 0.10'
|
||||
|
|
47
Gemfile.lock
47
Gemfile.lock
|
@ -103,21 +103,29 @@ GEM
|
|||
attr_required (1.0.1)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.780.0)
|
||||
aws-sdk-core (3.175.0)
|
||||
aws-partitions (1.791.0)
|
||||
aws-sdk-core (3.178.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.67.0)
|
||||
aws-sdk-core (~> 3, >= 3.174.0)
|
||||
aws-sdk-kms (1.71.0)
|
||||
aws-sdk-core (~> 3, >= 3.177.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.126.0)
|
||||
aws-sdk-core (~> 3, >= 3.174.0)
|
||||
aws-sdk-s3 (1.131.0)
|
||||
aws-sdk-core (~> 3, >= 3.177.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-sigv4 (~> 1.6)
|
||||
aws-sigv4 (1.6.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
azure-storage-common (2.0.4)
|
||||
faraday (~> 1.0)
|
||||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
bcrypt (3.1.18)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
|
@ -136,7 +144,7 @@ GEM
|
|||
blurhash (0.1.7)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.0.0)
|
||||
brakeman (6.0.1)
|
||||
browser (5.3.1)
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
|
@ -261,6 +269,8 @@ GEM
|
|||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.2.7)
|
||||
ffi (1.15.5)
|
||||
|
@ -297,7 +307,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.48.0)
|
||||
haml_lint (0.49.2)
|
||||
haml (>= 4.0, < 6.2)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
@ -410,6 +420,10 @@ GEM
|
|||
mario-redis-lock (1.2.1)
|
||||
redis (>= 3.0.5)
|
||||
matrix (0.4.2)
|
||||
md-paperclip-azure (2.2.0)
|
||||
addressable (~> 2.5)
|
||||
azure-storage-blob (~> 2.0.1)
|
||||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.1)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.4.1)
|
||||
|
@ -423,6 +437,8 @@ GEM
|
|||
multipart-post (2.3.0)
|
||||
net-http (0.3.2)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.3.6)
|
||||
date
|
||||
net-protocol
|
||||
|
@ -472,7 +488,7 @@ GEM
|
|||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
orm_adapter (0.5.0)
|
||||
ox (2.14.16)
|
||||
ox (2.14.17)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.3)
|
||||
ast (~> 2.4.1)
|
||||
|
@ -493,7 +509,7 @@ GEM
|
|||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
private_address_check (0.5.0)
|
||||
public_suffix (5.0.1)
|
||||
public_suffix (5.0.3)
|
||||
puma (6.3.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
|
@ -553,7 +569,7 @@ GEM
|
|||
rake (13.0.6)
|
||||
rdf (3.2.11)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.6.0)
|
||||
rdf-normalize (0.6.1)
|
||||
rdf (~> 3.2)
|
||||
redcarpet (3.6.0)
|
||||
redis (4.8.1)
|
||||
|
@ -567,7 +583,7 @@ GEM
|
|||
responders (3.1.0)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.2.5)
|
||||
rexml (3.2.6)
|
||||
rotp (6.2.2)
|
||||
rouge (4.1.2)
|
||||
rpam2 (4.0.2)
|
||||
|
@ -596,7 +612,7 @@ GEM
|
|||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.12.0)
|
||||
rspec_chunked (0.6)
|
||||
rubocop (1.54.1)
|
||||
rubocop (1.54.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
|
@ -822,6 +838,7 @@ DEPENDENCIES
|
|||
link_header (~> 0.0)
|
||||
lograge (~> 0.12)
|
||||
mario-redis-lock (~> 1.2)
|
||||
md-paperclip-azure (~> 2.2)
|
||||
memory_profiler
|
||||
mime-types (~> 3.4.1)
|
||||
net-http (~> 0.3.2)
|
||||
|
|
12
app/chewy/instances_index.rb
Normal file
12
app/chewy/instances_index.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class InstancesIndex < Chewy::Index
|
||||
settings index: { refresh_interval: '30s' }
|
||||
|
||||
index_scope ::Instance.searchable
|
||||
|
||||
root date_detection: false do
|
||||
field :domain, type: 'text', index_prefixes: { min_chars: 1 }
|
||||
field :accounts_count, type: 'long'
|
||||
end
|
||||
end
|
|
@ -15,7 +15,7 @@ class Api::V1::Instances::PeersController < Api::BaseController
|
|||
|
||||
def index
|
||||
cache_even_if_authenticated!
|
||||
render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) }
|
||||
render_with_cache(expires_in: 1.day) { Instance.searchable.pluck(:domain) }
|
||||
end
|
||||
|
||||
private
|
||||
|
|
45
app/controllers/api/v1/peers/search_controller.rb
Normal file
45
app/controllers/api/v1/peers/search_controller.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Peers::SearchController < Api::BaseController
|
||||
before_action :require_enabled_api!
|
||||
before_action :set_domains
|
||||
|
||||
skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
|
||||
skip_around_action :set_locale
|
||||
|
||||
vary_by ''
|
||||
|
||||
def index
|
||||
cache_even_if_authenticated!
|
||||
render json: @domains
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_enabled_api!
|
||||
head 404 unless Setting.peers_api_enabled && !whitelist_mode?
|
||||
end
|
||||
|
||||
def set_domains
|
||||
return if params[:q].blank?
|
||||
|
||||
if Chewy.enabled?
|
||||
@domains = InstancesIndex.query(function_score: {
|
||||
query: {
|
||||
prefix: {
|
||||
domain: params[:q],
|
||||
},
|
||||
},
|
||||
|
||||
field_value_factor: {
|
||||
field: 'accounts_count',
|
||||
modifier: 'log2p',
|
||||
},
|
||||
}).limit(10).pluck(:domain)
|
||||
else
|
||||
domain = params[:q].strip
|
||||
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)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -17,13 +17,16 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
|
|||
|
||||
if fav
|
||||
@status = fav.status
|
||||
count = [@status.favourites_count - 1, 0].max
|
||||
UnfavouriteWorker.perform_async(current_account.id, @status.id)
|
||||
else
|
||||
@status = Status.find(params[:status_id])
|
||||
count = @status.favourites_count
|
||||
authorize @status, :show?
|
||||
end
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false })
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
|
|
@ -24,15 +24,18 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||
|
||||
if @status
|
||||
authorize @status, :unreblog?
|
||||
@reblog = @status.reblog
|
||||
count = [@reblog.reblogs_count - 1, 0].max
|
||||
@status.discard
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
@reblog = @status.reblog
|
||||
else
|
||||
@reblog = Status.find(params[:status_id])
|
||||
count = @reblog.reblogs_count
|
||||
authorize @reblog, :show?
|
||||
end
|
||||
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false })
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
|
|
@ -19,6 +19,7 @@ class Api::V1::TagsController < Api::BaseController
|
|||
|
||||
def unfollow
|
||||
TagFollow.find_by(account: current_account, tag: @tag)&.destroy!
|
||||
TagUnmergeWorker.perform_async(@tag.id, current_account.id)
|
||||
render json: @tag, serializer: REST::TagSerializer
|
||||
end
|
||||
|
||||
|
|
|
@ -5,21 +5,13 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
|
||||
def self.provides_callback_for(provider)
|
||||
define_method provider do
|
||||
@provider = provider
|
||||
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
|
||||
|
||||
if @user.persisted?
|
||||
LoginActivity.create(
|
||||
user: @user,
|
||||
success: true,
|
||||
authentication_method: :omniauth,
|
||||
provider: provider,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
record_login_activity
|
||||
sign_in_and_redirect @user, event: :authentication
|
||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||
set_flash_message(:notice, :success, kind: label) if is_navigational_format?
|
||||
set_flash_message(:notice, :success, kind: label_for_provider) if is_navigational_format?
|
||||
else
|
||||
session["devise.#{provider}_data"] = request.env['omniauth.auth']
|
||||
redirect_to new_user_registration_url
|
||||
|
@ -38,4 +30,29 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
auth_setup_path(missing_email: '1')
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_login_activity
|
||||
LoginActivity.create(
|
||||
user: @user,
|
||||
success: true,
|
||||
authentication_method: :omniauth,
|
||||
provider: @provider,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
end
|
||||
|
||||
def label_for_provider
|
||||
provider_display_name || configured_provider_name
|
||||
end
|
||||
|
||||
def provider_display_name
|
||||
Devise.omniauth_configs[@provider]&.strategy&.display_name.presence
|
||||
end
|
||||
|
||||
def configured_provider_name
|
||||
I18n.t("auth.providers.#{@provider}", default: @provider.to_s.chomp('_oauth2').capitalize)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -113,7 +113,7 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
end
|
||||
|
||||
def home_paths(resource)
|
||||
paths = [about_path]
|
||||
paths = [about_path, '/explore']
|
||||
|
||||
paths << short_account_path(username: resource.account) if single_user_mode? && resource.is_a?(User)
|
||||
|
||||
|
|
|
@ -3,33 +3,19 @@
|
|||
class AuthorizeInteractionsController < ApplicationController
|
||||
include Authorization
|
||||
|
||||
layout 'modal'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_body_classes
|
||||
before_action :set_resource
|
||||
before_action :set_pack
|
||||
|
||||
def show
|
||||
if @resource.is_a?(Account)
|
||||
render :show
|
||||
redirect_to web_url("@#{@resource.pretty_acct}")
|
||||
elsif @resource.is_a?(Status)
|
||||
redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}")
|
||||
else
|
||||
render :error
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true)
|
||||
render :success
|
||||
else
|
||||
render :error
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render :error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_resource
|
||||
|
@ -62,12 +48,4 @@ class AuthorizeInteractionsController < ApplicationController
|
|||
def uri_param
|
||||
params[:uri] || params.fetch(:acct, '').delete_prefix('acct:')
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'modal-layout'
|
||||
end
|
||||
|
||||
def set_pack
|
||||
use_pack 'modal'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,7 +10,7 @@ class BackupsController < ApplicationController
|
|||
|
||||
def download
|
||||
case Paperclip::Attachment.default_options[:storage]
|
||||
when :s3
|
||||
when :s3, :azure
|
||||
redirect_to @backup.dump.expiring_url(10), allow_other_host: true
|
||||
when :fog
|
||||
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
|
||||
|
|
43
app/controllers/remote_interaction_helper_controller.rb
Normal file
43
app/controllers/remote_interaction_helper_controller.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoteInteractionHelperController < ApplicationController
|
||||
vary_by ''
|
||||
|
||||
skip_before_action :require_functional!
|
||||
skip_around_action :set_locale
|
||||
skip_before_action :update_user_sign_in
|
||||
|
||||
content_security_policy do |p|
|
||||
# We inherit the normal `script-src`
|
||||
|
||||
# Set every directive that does not have a fallback
|
||||
p.default_src :none
|
||||
p.form_action :none
|
||||
p.base_uri :none
|
||||
|
||||
# Disable every directive with a fallback to cut on response size
|
||||
p.base_uri false
|
||||
p.font_src false
|
||||
p.img_src false
|
||||
p.style_src false
|
||||
p.media_src false
|
||||
p.frame_src false
|
||||
p.manifest_src false
|
||||
p.connect_src false
|
||||
p.child_src false
|
||||
p.worker_src false
|
||||
|
||||
# Widen the directives that we do need
|
||||
p.frame_ancestors :self
|
||||
p.connect_src :https
|
||||
end
|
||||
|
||||
def index
|
||||
expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day)
|
||||
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
|
||||
render layout: 'helper_frame'
|
||||
end
|
||||
end
|
|
@ -19,6 +19,7 @@ module WellKnown
|
|||
|
||||
def set_account
|
||||
username = username_from_resource
|
||||
|
||||
@account = begin
|
||||
if username == Rails.configuration.x.local_domain
|
||||
Account.representative
|
||||
|
|
|
@ -236,6 +236,6 @@ module ApplicationHelper
|
|||
private
|
||||
|
||||
def storage_host_var
|
||||
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
|
||||
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil) || ENV.fetch('AZURE_ALIAS_HOST', nil)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,7 +22,14 @@ module ContextHelper
|
|||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
olm: {
|
||||
'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId',
|
||||
'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
|
||||
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
|
||||
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
|
||||
'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
|
||||
'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText'
|
||||
},
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
module DatabaseHelper
|
||||
def with_read_replica(&block)
|
||||
ApplicationRecord.connected_to(role: :read, prevent_writes: true, &block)
|
||||
ApplicationRecord.connected_to(role: :reading, prevent_writes: true, &block)
|
||||
end
|
||||
|
||||
def with_primary(&block)
|
||||
ApplicationRecord.connected_to(role: :primary, &block)
|
||||
ApplicationRecord.connected_to(role: :writing, &block)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -65,33 +65,6 @@ module StatusesHelper
|
|||
embedded_view? ? '_blank' : nil
|
||||
end
|
||||
|
||||
def style_classes(status, is_predecessor, is_successor, include_threads)
|
||||
classes = ['entry']
|
||||
classes << 'entry-predecessor' if is_predecessor
|
||||
classes << 'entry-reblog' if status.reblog?
|
||||
classes << 'entry-successor' if is_successor
|
||||
classes << 'entry-center' if include_threads
|
||||
classes.join(' ')
|
||||
end
|
||||
|
||||
def microformats_classes(status, is_direct_parent, is_direct_child)
|
||||
classes = []
|
||||
classes << 'p-in-reply-to' if is_direct_parent
|
||||
classes << 'p-repost-of' if status.reblog? && is_direct_parent
|
||||
classes << 'p-comment' if is_direct_child
|
||||
classes.join(' ')
|
||||
end
|
||||
|
||||
def microformats_h_class(status, is_predecessor, is_successor, include_threads)
|
||||
if is_predecessor || status.reblog? || is_successor
|
||||
'h-cite'
|
||||
elsif include_threads
|
||||
''
|
||||
else
|
||||
'h-entry'
|
||||
end
|
||||
end
|
||||
|
||||
def fa_visibility_icon(status)
|
||||
case status.visibility
|
||||
when 'public'
|
||||
|
|
172
app/javascript/core/remote_interaction_helper.ts
Normal file
172
app/javascript/core/remote_interaction_helper.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
|
||||
This script is meant to to be used in an `iframe` with the sole purpose of doing webfinger queries
|
||||
client-side without being restricted by a strict `connect-src` Content-Security-Policy directive.
|
||||
|
||||
It communicates with the parent window through message events that are authenticated by origin,
|
||||
and performs no other task.
|
||||
|
||||
*/
|
||||
|
||||
import 'packs/public-path';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
interface JRDLink {
|
||||
rel: string;
|
||||
template?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const isJRDLink = (link: unknown): link is JRDLink =>
|
||||
typeof link === 'object' &&
|
||||
link !== null &&
|
||||
'rel' in link &&
|
||||
typeof link.rel === 'string' &&
|
||||
(!('template' in link) || typeof link.template === 'string') &&
|
||||
(!('href' in link) || typeof link.href === 'string');
|
||||
|
||||
const findLink = (rel: string, data: unknown): JRDLink | undefined => {
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'links' in data &&
|
||||
data.links instanceof Array
|
||||
) {
|
||||
return data.links.find(
|
||||
(link): link is JRDLink => isJRDLink(link) && link.rel === rel,
|
||||
);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const findTemplateLink = (data: unknown) =>
|
||||
findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template;
|
||||
|
||||
const fetchInteractionURLSuccess = (
|
||||
uri_or_domain: string,
|
||||
template: string,
|
||||
) => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'fetchInteractionURL-success',
|
||||
uri_or_domain,
|
||||
template,
|
||||
},
|
||||
window.origin,
|
||||
);
|
||||
};
|
||||
|
||||
const fetchInteractionURLFailure = () => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'fetchInteractionURL-failure',
|
||||
},
|
||||
window.origin,
|
||||
);
|
||||
};
|
||||
|
||||
const isValidDomain = (value: string) => {
|
||||
const url = new URL('https:///path');
|
||||
url.hostname = value;
|
||||
return url.hostname === value;
|
||||
};
|
||||
|
||||
// Attempt to find a remote interaction URL from a domain
|
||||
const fromDomain = (domain: string) => {
|
||||
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
|
||||
|
||||
axios
|
||||
.get(`https://${domain}/.well-known/webfinger`, {
|
||||
params: { resource: `https://${domain}` },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
const template = findTemplateLink(data);
|
||||
fetchInteractionURLSuccess(domain, template ?? fallbackTemplate);
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
fetchInteractionURLSuccess(domain, fallbackTemplate);
|
||||
});
|
||||
};
|
||||
|
||||
// Attempt to find a remote interaction URL from an arbitrary URL
|
||||
const fromURL = (url: string) => {
|
||||
const domain = new URL(url).host;
|
||||
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
|
||||
|
||||
axios
|
||||
.get(`https://${domain}/.well-known/webfinger`, {
|
||||
params: { resource: url },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
const template = findTemplateLink(data);
|
||||
fetchInteractionURLSuccess(url, template ?? fallbackTemplate);
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
fromDomain(domain);
|
||||
});
|
||||
};
|
||||
|
||||
// Attempt to find a remote interaction URL from a `user@domain` string
|
||||
const fromAcct = (acct: string) => {
|
||||
acct = acct.replace(/^@/, '');
|
||||
|
||||
const segments = acct.split('@');
|
||||
|
||||
if (segments.length !== 2 || !segments[0] || !isValidDomain(segments[1])) {
|
||||
fetchInteractionURLFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = segments[1];
|
||||
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
|
||||
|
||||
axios
|
||||
.get(`https://${domain}/.well-known/webfinger`, {
|
||||
params: { resource: `acct:${acct}` },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
const template = findTemplateLink(data);
|
||||
fetchInteractionURLSuccess(acct, template ?? fallbackTemplate);
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: handle host-meta?
|
||||
fromDomain(domain);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchInteractionURL = (uri_or_domain: string) => {
|
||||
if (/^https?:\/\//.test(uri_or_domain)) {
|
||||
fromURL(uri_or_domain);
|
||||
} else if (uri_or_domain.includes('@')) {
|
||||
fromAcct(uri_or_domain);
|
||||
} else {
|
||||
fromDomain(uri_or_domain);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||
// Check message origin
|
||||
if (
|
||||
!window.origin ||
|
||||
window.parent !== event.source ||
|
||||
event.origin !== window.origin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
'type' in event.data &&
|
||||
event.data.type === 'fetchInteractionURL' &&
|
||||
'uri_or_domain' in event.data &&
|
||||
typeof event.data.uri_or_domain === 'string'
|
||||
) {
|
||||
fetchInteractionURL(event.data.uri_or_domain);
|
||||
}
|
||||
});
|
|
@ -18,3 +18,4 @@ pack:
|
|||
settings: settings.js
|
||||
sign_up:
|
||||
share:
|
||||
remote_interaction_helper: remote_interaction_helper.ts
|
||||
|
|
|
@ -81,7 +81,7 @@ export function importFetchedStatuses(statuses) {
|
|||
}
|
||||
|
||||
if (status.poll && status.poll.id) {
|
||||
pushUnique(polls, normalizePoll(status.poll));
|
||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,7 +95,7 @@ export function importFetchedStatuses(statuses) {
|
|||
}
|
||||
|
||||
export function importFetchedPoll(poll) {
|
||||
return dispatch => {
|
||||
dispatch(importPolls([normalizePoll(poll)]));
|
||||
return (dispatch, getState) => {
|
||||
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
|
||||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||
normalStatus.hidden = normalOldStatus.get('hidden');
|
||||
normalStatus.translation = normalOldStatus.get('translation');
|
||||
} else {
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
|
@ -86,6 +87,18 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
const list = normalOldStatus.get('media_attachments');
|
||||
if (normalStatus.media_attachments && list) {
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
const oldItem = list.find(i => i.get('id') === item.id);
|
||||
if (oldItem && oldItem.get('description') === item.description) {
|
||||
item.translation = oldItem.get('translation')
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalStatus;
|
||||
}
|
||||
|
||||
|
@ -104,15 +117,23 @@ export function normalizeStatusTranslation(translation, status) {
|
|||
return normalTranslation;
|
||||
}
|
||||
|
||||
export function normalizePoll(poll) {
|
||||
export function normalizePoll(poll, normalOldPoll) {
|
||||
const normalPoll = { ...poll };
|
||||
const emojiMap = makeEmojiMap(poll.emojis);
|
||||
|
||||
normalPoll.options = poll.options.map((option, index) => ({
|
||||
...option,
|
||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
||||
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
||||
}));
|
||||
normalPoll.options = poll.options.map((option, index) => {
|
||||
const normalOption = {
|
||||
...option,
|
||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
||||
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
||||
}
|
||||
|
||||
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
|
||||
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
|
||||
}
|
||||
|
||||
return normalOption
|
||||
});
|
||||
|
||||
return normalPoll;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,9 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
|||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||
|
||||
export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK';
|
||||
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
|
@ -28,7 +31,7 @@ export function clearSearch() {
|
|||
};
|
||||
}
|
||||
|
||||
export function submitSearch() {
|
||||
export function submitSearch(type) {
|
||||
return (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
@ -45,6 +48,7 @@ export function submitSearch() {
|
|||
q: value,
|
||||
resolve: signedIn,
|
||||
limit: 10,
|
||||
type,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.data.accounts) {
|
||||
|
@ -131,3 +135,42 @@ export const expandSearchFail = error => ({
|
|||
export const showSearch = () => ({
|
||||
type: SEARCH_SHOW,
|
||||
});
|
||||
|
||||
export const openURL = routerHistory => (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
||||
if (response.data.accounts?.length > 0) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
routerHistory.push(`/@${response.data.accounts[0].acct}`);
|
||||
} else if (response.data.statuses?.length > 0) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value));
|
||||
}).catch(err => {
|
||||
dispatch(fetchSearchFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export const clickSearchResult = (q, type) => ({
|
||||
type: SEARCH_RESULT_CLICK,
|
||||
|
||||
result: {
|
||||
type,
|
||||
q,
|
||||
},
|
||||
});
|
||||
|
||||
export const forgetSearchResult = q => ({
|
||||
type: SEARCH_RESULT_FORGET,
|
||||
q,
|
||||
});
|
||||
|
|
|
@ -20,9 +20,7 @@ export default class ColumnBackButton extends PureComponent {
|
|||
handleClick = () => {
|
||||
const { router } = this.context;
|
||||
|
||||
// Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201
|
||||
// When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
|
||||
if (router.route.location.key) {
|
||||
if (router.history.location?.state?.fromMastodon) {
|
||||
router.history.goBack();
|
||||
} else {
|
||||
router.history.push('/');
|
||||
|
|
|
@ -65,9 +65,7 @@ class ColumnHeader extends PureComponent {
|
|||
handleBackClick = () => {
|
||||
const { router } = this.context;
|
||||
|
||||
// Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201
|
||||
// When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
|
||||
if (router.route.location.key) {
|
||||
if (router.history.location?.state?.fromMastodon) {
|
||||
router.history.goBack();
|
||||
} else {
|
||||
router.history.push('/');
|
||||
|
@ -87,6 +85,7 @@ class ColumnHeader extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { router } = this.context;
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
|
@ -130,7 +129,7 @@ class ColumnHeader extends PureComponent {
|
|||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||
}
|
||||
|
||||
if (!pinned && (multiColumn || showBackButton)) {
|
||||
if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) {
|
||||
backButton = (
|
||||
<button onClick={this.handleBackClick} className='column-header__back-button'>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
|
|
|
@ -7,8 +7,7 @@ import { Helmet } from 'react-helmet';
|
|||
|
||||
import StackTrace from 'stacktrace-js';
|
||||
|
||||
import { source_url } from 'flavours/glitch/initial_state';
|
||||
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
||||
import { version, source_url } from 'flavours/glitch/initial_state';
|
||||
|
||||
export default class ErrorBoundary extends PureComponent {
|
||||
|
||||
|
@ -24,7 +23,7 @@ export default class ErrorBoundary extends PureComponent {
|
|||
componentStack: undefined,
|
||||
};
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
componentDidCatch (error, info) {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
errorMessage: error.toString(),
|
||||
|
@ -44,88 +43,62 @@ export default class ErrorBoundary extends PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
handleReload(e) {
|
||||
e.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
handleCopyStackTrace = () => {
|
||||
const { errorMessage, stackTrace, mappedStackTrace } = this.state;
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
let contents = [errorMessage, stackTrace];
|
||||
if (mappedStackTrace) {
|
||||
contents.push(mappedStackTrace);
|
||||
}
|
||||
|
||||
textarea.textContent = contents.join('\n\n\n');
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
this.setState({ copied: true });
|
||||
setTimeout(() => this.setState({ copied: false }), 700);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hasError, errorMessage, stackTrace, mappedStackTrace, componentStack } = this.state;
|
||||
const { hasError, copied, errorMessage } = this.state;
|
||||
|
||||
if (!hasError) return this.props.children;
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
|
||||
|
||||
let debugInfo = '';
|
||||
if (stackTrace) {
|
||||
debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```';
|
||||
}
|
||||
if (mappedStackTrace) {
|
||||
debugInfo += 'Mapped stack trace\n-----------\n\n```\n' + errorMessage + '\n' + mappedStackTrace.toString() + '\n```';
|
||||
}
|
||||
if (componentStack) {
|
||||
if (debugInfo) {
|
||||
debugInfo += '\n\n\n';
|
||||
}
|
||||
debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
|
||||
}
|
||||
|
||||
let issueTracker = source_url;
|
||||
if (source_url.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
|
||||
issueTracker = source_url + '/issues';
|
||||
}
|
||||
|
||||
return (
|
||||
<div tabIndex={-1}>
|
||||
<div className='error-boundary'>
|
||||
<h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
|
||||
<p>
|
||||
<FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
|
||||
</p>
|
||||
<ul>
|
||||
{ likelyBrowserAddonIssue && (
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.disable_addons'
|
||||
defaultMessage='Disable browser add-ons or built-in translation tools'
|
||||
/>
|
||||
</li>
|
||||
) }
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.report_issue'
|
||||
defaultMessage='Report a bug in the {issuetracker}'
|
||||
values={{ issuetracker: <a href={issueTracker} rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
|
||||
/>
|
||||
{ debugInfo !== '' && (
|
||||
<details>
|
||||
<summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
|
||||
<textarea
|
||||
className='web_app_crash-stacktrace'
|
||||
value={debugInfo}
|
||||
rows='10'
|
||||
readOnly
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.reload_page'
|
||||
defaultMessage='{reload} the current page'
|
||||
values={{ reload: <button onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></button> }}
|
||||
/>
|
||||
</li>
|
||||
{ preferencesLink !== undefined && (
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='web_app_crash.change_your_settings'
|
||||
defaultMessage='Change your {settings}'
|
||||
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
|
||||
/>
|
||||
</li>
|
||||
<div className='error-boundary'>
|
||||
<div>
|
||||
<p className='error-boundary__error'>
|
||||
{ likelyBrowserAddonIssue ? (
|
||||
<FormattedMessage id='error.unexpected_crash.explanation_addons' defaultMessage='This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.' />
|
||||
) : (
|
||||
<FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' />
|
||||
)}
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{ likelyBrowserAddonIssue ? (
|
||||
<FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
|
||||
) : (
|
||||
<FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { History } from 'history';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { Router as OriginalRouter } from 'react-router';
|
||||
|
||||
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
|
||||
|
||||
const browserHistory = createBrowserHistory();
|
||||
const originalPush = browserHistory.push.bind(browserHistory);
|
||||
interface MastodonLocationState {
|
||||
fromMastodon?: boolean;
|
||||
mastodonModalKey?: string;
|
||||
}
|
||||
|
||||
const browserHistory = createBrowserHistory<
|
||||
MastodonLocationState | undefined
|
||||
>();
|
||||
const originalPush = browserHistory.push.bind(browserHistory);
|
||||
const originalReplace = browserHistory.replace.bind(browserHistory);
|
||||
|
||||
browserHistory.push = (path: string, state?: MastodonLocationState) => {
|
||||
state = state ?? {};
|
||||
state.fromMastodon = true;
|
||||
|
||||
browserHistory.push = (path: string, state: History.LocationState) => {
|
||||
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
|
||||
originalPush(`/deck${path}`, state);
|
||||
} else {
|
||||
|
@ -18,6 +28,19 @@ browserHistory.push = (path: string, state: History.LocationState) => {
|
|||
}
|
||||
};
|
||||
|
||||
browserHistory.replace = (path: string, state?: MastodonLocationState) => {
|
||||
if (browserHistory.location.state?.fromMastodon) {
|
||||
state = state ?? {};
|
||||
state.fromMastodon = true;
|
||||
}
|
||||
|
||||
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
|
||||
originalReplace(`/deck${path}`, state);
|
||||
} else {
|
||||
originalReplace(path, state);
|
||||
}
|
||||
};
|
||||
|
||||
export const Router: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
|
||||
};
|
||||
|
|
|
@ -32,7 +32,7 @@ const messages = defineMessages({
|
|||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
|
|
|
@ -55,7 +55,7 @@ export default class StatusPrepend extends PureComponent {
|
|||
return (
|
||||
<FormattedMessage
|
||||
id='notification.favourite'
|
||||
defaultMessage='{name} favourited your status'
|
||||
defaultMessage='{name} favorited your status'
|
||||
values={{ name : link }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -49,7 +49,7 @@ const messages = defineMessages({
|
|||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
|
||||
|
@ -303,7 +303,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
modalProps: {
|
||||
type,
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
|
|
@ -46,7 +46,7 @@ const messages = defineMessages({
|
|||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
|
|
|
@ -130,7 +130,11 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||
<div className='media-gallery__gifv'>
|
||||
{content}
|
||||
|
||||
{label && <span className='media-gallery__gifv__label'>{label}</span>}
|
||||
{label && (
|
||||
<div className='media-gallery__item__badges'>
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: account.get('id'),
|
||||
url: account.get('url'),
|
||||
url: account.get('uri'),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
|
|
@ -477,6 +477,7 @@ class Audio extends PureComponent {
|
|||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
|
@ -522,7 +523,10 @@ class Audio extends PureComponent {
|
|||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ const messages = defineMessages({
|
|||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
|
|
|
@ -7,39 +7,21 @@ import {
|
|||
defineMessages,
|
||||
} from 'react-intl';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { searchEnabled } from 'flavours/glitch/initial_state';
|
||||
import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
|
||||
import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
||||
});
|
||||
|
||||
class SearchPopout extends PureComponent {
|
||||
|
||||
render () {
|
||||
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
|
||||
return (
|
||||
<div className='search-popout'>
|
||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
|
||||
|
||||
<ul>
|
||||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
|
||||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
|
||||
</ul>
|
||||
|
||||
{extraInformation}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// The component.
|
||||
class Search extends PureComponent {
|
||||
|
||||
|
@ -50,9 +32,13 @@ class Search extends PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
recent: ImmutablePropTypes.orderedSet,
|
||||
submitted: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onOpenURL: PropTypes.func.isRequired,
|
||||
onClickSearchResult: PropTypes.func.isRequired,
|
||||
onForgetSearchResult: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func.isRequired,
|
||||
openInRoute: PropTypes.bool,
|
||||
|
@ -62,59 +48,104 @@ class Search extends PureComponent {
|
|||
|
||||
state = {
|
||||
expanded: false,
|
||||
selectedOption: -1,
|
||||
options: [],
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.searchForm = c;
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
handleChange = ({ target }) => {
|
||||
const { onChange } = this.props;
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
|
||||
onChange(target.value);
|
||||
|
||||
this._calculateOptions(target.value);
|
||||
};
|
||||
|
||||
handleClear = (e) => {
|
||||
handleClear = e => {
|
||||
const {
|
||||
onClear,
|
||||
submitted,
|
||||
value,
|
||||
} = this.props;
|
||||
|
||||
e.preventDefault(); // Prevents focus change ??
|
||||
if (onClear && (submitted || value && value.length)) {
|
||||
|
||||
if (value.length > 0 || submitted) {
|
||||
onClear();
|
||||
this.setState({ options: [], selectedOption: -1 })
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false });
|
||||
this.setState({ expanded: false, selectedOption: -1 });
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
this.props.onShow();
|
||||
const { onShow, singleColumn } = this.props;
|
||||
|
||||
if (this.searchForm && !this.props.singleColumn) {
|
||||
this.setState({ expanded: true, selectedOption: -1 });
|
||||
onShow();
|
||||
|
||||
if (this.searchForm && !singleColumn) {
|
||||
const { left, right } = this.searchForm.getBoundingClientRect();
|
||||
|
||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||
this.searchForm.scrollIntoView();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
const { onSubmit } = this.props;
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
onSubmit();
|
||||
handleKeyDown = (e) => {
|
||||
const { selectedOption } = this.state;
|
||||
const options = this._getOptions();
|
||||
|
||||
if (this.props.openInRoute) {
|
||||
this.context.router.history.push('/search');
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
|
||||
focusRoot();
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
this._submit();
|
||||
} else if (options.length > 0) {
|
||||
options[selectedOption].action();
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && options.length > 0) {
|
||||
const search = options[selectedOption];
|
||||
|
||||
if (typeof search.forget === 'function') {
|
||||
e.preventDefault();
|
||||
search.forget(e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
focusRoot();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -122,14 +153,141 @@ class Search extends PureComponent {
|
|||
return this.searchForm;
|
||||
};
|
||||
|
||||
handleHashtagClick = () => {
|
||||
const { router } = this.context;
|
||||
const { value, onClickSearchResult } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^#/, '');
|
||||
|
||||
router.history.push(`/tags/${query}`);
|
||||
onClickSearchResult(query, 'hashtag');
|
||||
};
|
||||
|
||||
handleAccountClick = () => {
|
||||
const { router } = this.context;
|
||||
const { value, onClickSearchResult } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^@/, '');
|
||||
|
||||
router.history.push(`/@${query}`);
|
||||
onClickSearchResult(query, 'account');
|
||||
};
|
||||
|
||||
handleURLClick = () => {
|
||||
const { router } = this.context;
|
||||
const { onOpenURL } = this.props;
|
||||
|
||||
onOpenURL(router.history);
|
||||
};
|
||||
|
||||
handleStatusSearch = () => {
|
||||
this._submit('statuses');
|
||||
};
|
||||
|
||||
handleAccountSearch = () => {
|
||||
this._submit('accounts');
|
||||
};
|
||||
|
||||
handleRecentSearchClick = search => {
|
||||
const { router } = this.context;
|
||||
|
||||
if (search.get('type') === 'account') {
|
||||
router.history.push(`/@${search.get('q')}`);
|
||||
} else if (search.get('type') === 'hashtag') {
|
||||
router.history.push(`/tags/${search.get('q')}`);
|
||||
}
|
||||
};
|
||||
|
||||
handleForgetRecentSearchClick = search => {
|
||||
const { onForgetSearchResult } = this.props;
|
||||
|
||||
onForgetSearchResult(search.get('q'));
|
||||
};
|
||||
|
||||
_unfocus () {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
_submit (type) {
|
||||
const { onSubmit, openInRoute } = this.props;
|
||||
const { router } = this.context;
|
||||
|
||||
onSubmit(type);
|
||||
|
||||
if (openInRoute) {
|
||||
router.history.push('/search');
|
||||
}
|
||||
}
|
||||
|
||||
_getOptions () {
|
||||
const { options } = this.state;
|
||||
|
||||
if (options.length > 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const { recent } = this.props;
|
||||
|
||||
return recent.toArray().map(search => ({
|
||||
label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`,
|
||||
|
||||
action: () => this.handleRecentSearchClick(search),
|
||||
|
||||
forget: e => {
|
||||
e.stopPropagation();
|
||||
this.handleForgetRecentSearchClick(search);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_calculateOptions (value) {
|
||||
const trimmedValue = value.trim();
|
||||
const options = [];
|
||||
|
||||
if (trimmedValue.length > 0) {
|
||||
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||
|
||||
if (couldBeURL) {
|
||||
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
|
||||
}
|
||||
|
||||
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
|
||||
|
||||
if (couldBeHashtag) {
|
||||
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
|
||||
}
|
||||
|
||||
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
|
||||
|
||||
if (couldBeUsername) {
|
||||
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
|
||||
}
|
||||
|
||||
const couldBeStatusSearch = searchEnabled;
|
||||
|
||||
if (couldBeStatusSearch) {
|
||||
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
|
||||
}
|
||||
|
||||
const couldBeUserSearch = true;
|
||||
|
||||
if (couldBeUserSearch) {
|
||||
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ options });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, value, submitted } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const { intl, value, submitted, recent } = this.props;
|
||||
const { expanded, options, selectedOption } = this.state;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
<div className='search'>
|
||||
<div className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className='search__input'
|
||||
|
@ -138,7 +296,7 @@ class Search extends PureComponent {
|
|||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
value={value || ''}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
/>
|
||||
|
@ -147,15 +305,39 @@ class Search extends PureComponent {
|
|||
<Icon id='search' className={hasValue ? '' : 'active'} />
|
||||
<Icon id='times-circle' className={hasValue ? 'active' : ''} />
|
||||
</div>
|
||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props, placement }) => (
|
||||
<div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
|
||||
<div className={`dropdown-animation ${placement}`}>
|
||||
<SearchPopout />
|
||||
<div className='search__popout'>
|
||||
{options.length === 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => (
|
||||
<button key={label} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
|
||||
<span>{label}</span>
|
||||
<button className='icon-button' onMouseDown={forget}><Icon id='times' /></button>
|
||||
</button>
|
||||
)) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Overlay>
|
||||
{options.length > 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{options.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ class SearchResults extends ImmutablePureComponent {
|
|||
count += results.get('accounts').size;
|
||||
accounts = (
|
||||
<section className='search-results__section'>
|
||||
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
||||
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5>
|
||||
|
||||
{results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
|
||||
|
||||
|
|
|
@ -5,6 +5,9 @@ import {
|
|||
clearSearch,
|
||||
submitSearch,
|
||||
showSearch,
|
||||
openURL,
|
||||
clickSearchResult,
|
||||
forgetSearchResult,
|
||||
} from 'flavours/glitch/actions/search';
|
||||
|
||||
import Search from '../components/search';
|
||||
|
@ -12,6 +15,7 @@ import Search from '../components/search';
|
|||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['search', 'value']),
|
||||
submitted: state.getIn(['search', 'submitted']),
|
||||
recent: state.getIn(['search', 'recent']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
@ -24,14 +28,26 @@ const mapDispatchToProps = dispatch => ({
|
|||
dispatch(clearSearch());
|
||||
},
|
||||
|
||||
onSubmit () {
|
||||
dispatch(submitSearch());
|
||||
onSubmit (type) {
|
||||
dispatch(submitSearch(type));
|
||||
},
|
||||
|
||||
onShow () {
|
||||
dispatch(showSearch());
|
||||
},
|
||||
|
||||
onOpenURL (routerHistory) {
|
||||
dispatch(openURL(routerHistory));
|
||||
},
|
||||
|
||||
onClickSearchResult (q, type) {
|
||||
dispatch(clickSearchResult(q, type));
|
||||
},
|
||||
|
||||
onForgetSearchResult (q) {
|
||||
dispatch(forgetSearchResult(q));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
||||
|
|
|
@ -6,37 +6,14 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
|
||||
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||
|
||||
import Warning from '../components/warning';
|
||||
|
||||
const buildHashtagRE = () => {
|
||||
try {
|
||||
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
|
||||
const ALPHA = '\\p{L}\\p{M}';
|
||||
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||
return new RegExp(
|
||||
'(?:^|[^\\/\\)\\w])#((' +
|
||||
'[' + WORD + '_]' +
|
||||
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
|
||||
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
|
||||
'[' + WORD + HASHTAG_SEPARATORS +']*' +
|
||||
'[' + WORD + '_]' +
|
||||
')|(' +
|
||||
'[' + WORD + '_]*' +
|
||||
'[' + ALPHA + ']' +
|
||||
'[' + WORD + '_]*' +
|
||||
'))', 'iu',
|
||||
);
|
||||
} catch {
|
||||
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
||||
}
|
||||
};
|
||||
|
||||
const APPROX_HASHTAG_RE = buildHashtagRE();
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
||||
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
|
||||
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||
});
|
||||
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
|
||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
|
||||
|
@ -14,10 +17,14 @@ export default class Story extends PureComponent {
|
|||
static propTypes = {
|
||||
url: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
publisher: PropTypes.string,
|
||||
publishedAt: PropTypes.string,
|
||||
author: PropTypes.string,
|
||||
sharedTimes: PropTypes.number,
|
||||
thumbnail: PropTypes.string,
|
||||
blurhash: PropTypes.string,
|
||||
expanded: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -27,16 +34,16 @@ export default class Story extends PureComponent {
|
|||
handleImageLoad = () => this.setState({ thumbnailLoaded: true });
|
||||
|
||||
render () {
|
||||
const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
|
||||
const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, blurhash } = this.props;
|
||||
|
||||
const { thumbnailLoaded } = this.state;
|
||||
|
||||
return (
|
||||
<a className='story' href={url} target='blank' rel='noopener'>
|
||||
<a className={classNames('story', { expanded })} href={url} target='blank' rel='noopener'>
|
||||
<div className='story__details'>
|
||||
<div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
|
||||
<div className='story__details__title'>{title ? title : <Skeleton />}</div>
|
||||
<div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
|
||||
<div className='story__details__publisher'>{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}</div>
|
||||
<div className='story__details__title' lang={lang}>{title ? title : <Skeleton />}</div>
|
||||
<div className='story__details__shared'>{author && <><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{author}</strong> }} /> · </>}{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
|
||||
</div>
|
||||
|
||||
<div className='story__thumbnail'>
|
||||
|
|
|
@ -55,12 +55,16 @@ class Links extends PureComponent {
|
|||
<div className='explore__links'>
|
||||
{banner}
|
||||
|
||||
{isLoading ? (<LoadingIndicator />) : links.map(link => (
|
||||
{isLoading ? (<LoadingIndicator />) : links.map((link, i) => (
|
||||
<Story
|
||||
key={link.get('id')}
|
||||
expanded={i === 0}
|
||||
lang={link.get('language')}
|
||||
url={link.get('url')}
|
||||
title={link.get('title')}
|
||||
publisher={link.get('provider_name')}
|
||||
publishedAt={link.get('published_at')}
|
||||
author={link.get('author_name')}
|
||||
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||
thumbnail={link.get('image')}
|
||||
blurhash={link.get('blurhash')}
|
||||
|
|
|
@ -111,7 +111,7 @@ class Results extends PureComponent {
|
|||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
|
|
@ -47,7 +47,7 @@ class Statuses extends PureComponent {
|
|||
return (
|
||||
<>
|
||||
<DismissableBanner id='explore/statuses'>
|
||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.' />
|
||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' />
|
||||
</DismissableBanner>
|
||||
|
||||
<StatusList
|
||||
|
|
|
@ -18,7 +18,7 @@ import Column from 'flavours/glitch/features/ui/components/column';
|
|||
import { getStatusList } from 'flavours/glitch/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favorites' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
@ -74,7 +74,7 @@ class Favourites extends ImmutablePureComponent {
|
|||
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}>
|
||||
|
|
|
@ -71,7 +71,7 @@ class Favourites extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this post yet. When someone does, they will show up here.' />;
|
||||
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favorited this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
|
|
|
@ -76,12 +76,12 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
const { signedIn } = useIdentity();
|
||||
const columnRef = useRef(null);
|
||||
|
||||
const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false));
|
||||
const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0);
|
||||
|
||||
const allowLocalOnly = useAppSelector((state) => state.getIn(['settings', 'firehose', 'allowLocalOnly']));
|
||||
const regex = useAppSelector((state) => state.getIn(['settings', 'firehose', 'regex', 'body']));
|
||||
|
||||
const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false));
|
||||
const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${feedType === 'public' && allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0);
|
||||
|
||||
const handlePin = useCallback(
|
||||
() => {
|
||||
switch(feedType) {
|
||||
|
@ -129,7 +129,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
}
|
||||
break;
|
||||
case 'public':
|
||||
dispatch(expandPublicTimeline({ onlyMedia }));
|
||||
dispatch(expandPublicTimeline({ onlyMedia, allowLocalOnly }));
|
||||
if (signedIn) {
|
||||
disconnect = dispatch(connectPublicStream({ onlyMedia, allowLocalOnly }));
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subh
|
|||
const messages = defineMessages({
|
||||
heading: { id: 'column.heading', defaultMessage: 'Misc' },
|
||||
subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
|
|
|
@ -22,7 +22,7 @@ export const ExplorePrompt = () => (
|
|||
<p>
|
||||
<FormattedMessage
|
||||
id='home.explore_prompt.body'
|
||||
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:"
|
||||
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:"
|
||||
/>
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,95 +1,296 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { throttle, escapeRegExp } from 'lodash';
|
||||
|
||||
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||
import api from 'flavours/glitch/api';
|
||||
import Button from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { registrationsOpen } from 'flavours/glitch/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
|
||||
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onSignupClick() {
|
||||
dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: false,
|
||||
}));
|
||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
||||
dispatch(closeModal());
|
||||
dispatch(openModal('CLOSED_REGISTRATIONS'));
|
||||
},
|
||||
});
|
||||
|
||||
class Copypaste extends PureComponent {
|
||||
const PERSISTENCE_KEY = 'flavours/glitch_home';
|
||||
|
||||
const isValidDomain = value => {
|
||||
const url = new URL('https:///path');
|
||||
url.hostname = value;
|
||||
return url.hostname === value;
|
||||
};
|
||||
|
||||
const valueToDomain = value => {
|
||||
// If the user starts typing an URL
|
||||
if (/^https?:\/\//.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
// Consider that if there is a path, the URL is more meaningful than a bare domain
|
||||
if (url.pathname.length > 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.host;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// If the user writes their full handle including username
|
||||
} else if (value.includes('@')) {
|
||||
if (value.replace(/^@/, '').split('@').length > 2) {
|
||||
return undefined;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const addInputToOptions = (value, options) => {
|
||||
value = value.trim();
|
||||
|
||||
if (value.includes('.') && isValidDomain(value)) {
|
||||
return [value].concat(options.filter((x) => x !== value));
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
class LoginForm extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
resourceUrl: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '',
|
||||
expanded: false,
|
||||
selectedOption: -1,
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
error: false,
|
||||
options: [],
|
||||
networkOptions: [],
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.input = c;
|
||||
};
|
||||
|
||||
handleInputClick = () => {
|
||||
this.setState({ copied: false });
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
this.input.setSelectionRange(0, this.input.value.length);
|
||||
handleChange = ({ target }) => {
|
||||
this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions());
|
||||
};
|
||||
|
||||
handleButtonClick = () => {
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.input.blur();
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
handleMessage = (event) => {
|
||||
const { resourceUrl } = this.props;
|
||||
|
||||
if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === 'fetchInteractionURL-failure') {
|
||||
this.setState({ isSubmitting: false, error: true });
|
||||
} else if (event.data?.type === 'fetchInteractionURL-success') {
|
||||
if (/^https?:\/\//.test(event.data.template)) {
|
||||
if (localStorage) {
|
||||
localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain);
|
||||
}
|
||||
|
||||
window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl));
|
||||
} else {
|
||||
this.setState({ isSubmitting: false, error: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
componentDidMount () {
|
||||
window.addEventListener('message', this.handleMessage);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('message', this.handleMessage);
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
const { value } = this.state;
|
||||
|
||||
this.setState({ isSubmitting: true });
|
||||
|
||||
this.iframeRef.contentWindow.postMessage({
|
||||
type: 'fetchInteractionURL',
|
||||
uri_or_domain: value.trim(),
|
||||
}, window.origin);
|
||||
};
|
||||
|
||||
setIFrameRef = (iframe) => {
|
||||
this.iframeRef = iframe;
|
||||
}
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false });
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { options, selectedOption } = this.state;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
this.handleSubmit();
|
||||
} else if (options.length > 0) {
|
||||
this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleOptionClick = e => {
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const option = this.state.options[index];
|
||||
|
||||
e.preventDefault();
|
||||
this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit());
|
||||
};
|
||||
|
||||
_loadOptions = throttle(() => {
|
||||
const { value } = this.state;
|
||||
|
||||
const domain = valueToDomain(value.trim());
|
||||
|
||||
if (typeof domain === 'undefined') {
|
||||
this.setState({ options: [], networkOptions: [], isLoading: false, error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (domain.length === 0) {
|
||||
this.setState({ options: [], networkOptions: [], isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => {
|
||||
if (!data) {
|
||||
data = [];
|
||||
}
|
||||
|
||||
this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false }));
|
||||
}).catch(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
render () {
|
||||
const { value } = this.props;
|
||||
const { copied } = this.state;
|
||||
const { intl } = this.props;
|
||||
const { value, expanded, options, selectedOption, error, isSubmitting } = this.state;
|
||||
const domain = (valueToDomain(value) || '').trim();
|
||||
const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi');
|
||||
const hasPopOut = domain.length > 0 && options.length > 0;
|
||||
|
||||
return (
|
||||
<div className={classNames('copypaste', { copied })}>
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setRef}
|
||||
value={value}
|
||||
readOnly
|
||||
onClick={this.handleInputClick}
|
||||
<div className={classNames('interaction-modal__login', { focused: expanded, expanded: hasPopOut, invalid: error })}>
|
||||
|
||||
<iframe
|
||||
ref={this.setIFrameRef}
|
||||
style={{display: 'none'}}
|
||||
src='/remote_interaction_helper'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='remote interaction helper'
|
||||
/>
|
||||
|
||||
<button className='button' onClick={this.handleButtonClick}>
|
||||
{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy' defaultMessage='Copy' />}
|
||||
</button>
|
||||
<div className='interaction-modal__login__input'>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
type='text'
|
||||
value={value}
|
||||
placeholder={intl.formatMessage(messages.loginPrompt)}
|
||||
aria-label={intl.formatMessage(messages.loginPrompt)}
|
||||
autoFocus
|
||||
onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
/>
|
||||
|
||||
<Button onClick={this.handleSubmit} disabled={isSubmitting}><FormattedMessage id='interaction_modal.login.action' defaultMessage='Take me home' /></Button>
|
||||
</div>
|
||||
|
||||
{hasPopOut && (
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout__menu'>
|
||||
{options.map((option, i) => (
|
||||
<button key={option} onMouseDown={this.handleOptionClick} data-index={i} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
||||
{option.split(domainRegExp).map((part, i) => (
|
||||
part.toLowerCase() === domain.toLowerCase() ? (
|
||||
<mark key={i}>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class InteractionModal extends PureComponent {
|
||||
const IntlLoginForm = injectIntl(LoginForm);
|
||||
|
||||
class InteractionModal extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
displayNameHtml: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
|
||||
onSignupClick: PropTypes.func.isRequired,
|
||||
signupUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
handleSignupClick = () => {
|
||||
|
@ -97,7 +298,7 @@ class InteractionModal extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { url, type, displayNameHtml, signupUrl } = this.props;
|
||||
const { url, type, displayNameHtml } = this.props;
|
||||
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
|
||||
|
@ -116,8 +317,8 @@ class InteractionModal extends PureComponent {
|
|||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favourite {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.' />;
|
||||
title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favorite {name}'s post" values={{ name }} />;
|
||||
actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.' />;
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' />;
|
||||
|
@ -130,13 +331,13 @@ class InteractionModal extends PureComponent {
|
|||
|
||||
if (registrationsOpen) {
|
||||
signupButton = (
|
||||
<a href={signupUrl} className='button button--block button-tertiary'>
|
||||
<a href='/auth/sign_up' className='link-button'>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
signupButton = (
|
||||
<button className='button button--block button-tertiary' onClick={this.handleSignupClick}>
|
||||
<button className='link-button' onClick={this.handleSignupClick}>
|
||||
<FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
|
||||
</button>
|
||||
);
|
||||
|
@ -146,22 +347,13 @@ class InteractionModal extends PureComponent {
|
|||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
|
||||
<p>{actionDescription} <FormattedMessage id='interaction_modal.preamble' defaultMessage="Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one." /></p>
|
||||
<p>{actionDescription} <strong><FormattedMessage id='interaction_modal.sign_in' defaultMessage='You are not logged in to this server. Where is your account hosted?' /></strong></p>
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices'>
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
||||
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||
{signupButton}
|
||||
</div>
|
||||
<IntlLoginForm resourceUrl={url} />
|
||||
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
||||
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Copy and paste this URL into the search field of your favourite Mastodon app or the web interface of your Mastodon server.' /></p>
|
||||
<Copypaste value={url} />
|
||||
</div>
|
||||
</div>
|
||||
<p className='hint'><FormattedMessage id='interaction_modal.sign_in_hint' defaultMessage="Tip: That's the website where you signed up. If you don't remember, look for the welcome e-mail in your inbox. You can also enter your full username! (e.g. @Mastodon@mastodon.social)" /></p>
|
||||
<p><FormattedMessage id='interaction_modal.no_account_yet' defaultMessage='Not on Mastodon?' /> {signupButton}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
</tr>
|
||||
<tr>
|
||||
<td><kbd>f</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favourite' /></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favorite' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>b</kbd></td>
|
||||
|
|
|
@ -60,7 +60,6 @@ export default class LocalSettingsPage extends PureComponent {
|
|||
else if (onNavigate) return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
className={finalClassName}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
|
|
|
@ -110,7 +110,7 @@ export default class ColumnSettings extends PureComponent {
|
|||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-favourite'>
|
||||
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
|
||||
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></span>
|
||||
|
||||
<div className='column-settings__pillbar'>
|
||||
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Icon } from 'flavours/glitch/components/icon';
|
|||
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
|
||||
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
|
|
|
@ -23,7 +23,7 @@ const messages = defineMessages({
|
|||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
|
@ -93,7 +93,7 @@ class Footer extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ class Footer extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -144,7 +144,7 @@ class Footer extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ const mapStateToProps = (state, { columnId }) => {
|
|||
const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
|
||||
const allowLocalOnly = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'allowLocalOnly']) : state.getIn(['settings', 'public', 'other', 'allowLocalOnly']);
|
||||
const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'public', 'regex', 'body']);
|
||||
const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
|
||||
const timelineState = state.getIn(['timelines', `public${onlyRemote ? ':remote' : allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`]);
|
||||
|
||||
return {
|
||||
hasUnread: !!timelineState && timelineState.get('unread') > 0,
|
||||
|
|
|
@ -25,7 +25,7 @@ const messages = defineMessages({
|
|||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
|
|
|
@ -181,7 +181,10 @@ export default class Card extends PureComponent {
|
|||
let thumbnail = <img src={card.get('image')} alt='' style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
|
||||
let spoilerButton = (
|
||||
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
<FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
spoilerButton = (
|
||||
|
|
|
@ -35,7 +35,7 @@ const messages = defineMessages({
|
|||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
|
|
@ -68,7 +68,7 @@ const messages = defineMessages({
|
|||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
|
||||
statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}' },
|
||||
|
@ -194,8 +194,8 @@ class Status extends ImmutablePureComponent {
|
|||
status: ImmutablePropTypes.map,
|
||||
isLoading: PropTypes.bool,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
ancestorsIds: ImmutablePropTypes.list,
|
||||
descendantsIds: ImmutablePropTypes.list,
|
||||
ancestorsIds: ImmutablePropTypes.list.isRequired,
|
||||
descendantsIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
|
@ -219,16 +219,6 @@ class Status extends ImmutablePureComponent {
|
|||
componentDidMount () {
|
||||
attachFullscreenListener(this.onFullScreenChange);
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId));
|
||||
|
||||
const { status, ancestorsIds } = this.props;
|
||||
|
||||
if (status && ancestorsIds && ancestorsIds.size > 0) {
|
||||
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
element.scrollIntoView(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
|
@ -307,7 +297,7 @@ class Status extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -358,7 +348,7 @@ class Status extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -392,7 +382,7 @@ class Status extends ImmutablePureComponent {
|
|||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -640,16 +630,22 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) {
|
||||
const { status, ancestorsIds } = this.props;
|
||||
const { status, ancestorsIds, multiColumn } = this.props;
|
||||
|
||||
if (status && ancestorsIds && ancestorsIds.size > 0) {
|
||||
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
|
||||
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.node?.querySelector('.detailed-status__wrapper')?.scrollIntoView(true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
element.scrollIntoView(true);
|
||||
});
|
||||
}
|
||||
// In the single-column interface, `scrollIntoView` will put the post behind the header,
|
||||
// so compensate for that.
|
||||
if (!multiColumn) {
|
||||
const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom;
|
||||
if (offset) {
|
||||
const scrollingElement = document.scrollingElement || document.body;
|
||||
scrollingElement.scrollBy(0, -offset);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -716,16 +712,16 @@ class Status extends ImmutablePureComponent {
|
|||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
||||
{ancestors}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='focusable' tabIndex={0} aria-label={textForScreenReader(intl, status, false, isExpanded)}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false, isExpanded)}>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
status={status}
|
||||
|
|
761
app/javascript/flavours/glitch/features/status/index.jsx.orig
Normal file
761
app/javascript/flavours/glitch/features/status/index.jsx.orig
Normal file
|
@ -0,0 +1,761 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import Immutable from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import {
|
||||
favourite,
|
||||
unfavourite,
|
||||
bookmark,
|
||||
unbookmark,
|
||||
reblog,
|
||||
unreblog,
|
||||
pin,
|
||||
unpin,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import {
|
||||
fetchStatus,
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
deleteStatus,
|
||||
editStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
} from 'flavours/glitch/actions/statuses';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
import ActionBar from './components/action_bar';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
|
||||
statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}' },
|
||||
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
tootHeading: { id: 'account.posts_with_replies', defaultMessage: 'Posts and replies' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'inReplyTos']),
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = Immutable.List();
|
||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
while (id && !mutable.includes(id)) {
|
||||
mutable.unshift(id);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
});
|
||||
|
||||
return ancestorsIds;
|
||||
});
|
||||
|
||||
const getDescendantsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'replies']),
|
||||
state => state.get('statuses'),
|
||||
], (statusId, contextReplies, statuses) => {
|
||||
let descendantsIds = [];
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
let id = ids.pop();
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
descendantsIds.push(id);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach(reply => {
|
||||
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
||||
if (insertAt !== -1) {
|
||||
descendantsIds.forEach((id, idx) => {
|
||||
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
||||
descendantsIds.splice(idx, 1);
|
||||
descendantsIds.splice(insertAt, 0, id);
|
||||
insertAt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Immutable.List(descendantsIds);
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
|
||||
let ancestorsIds = Immutable.List();
|
||||
let descendantsIds = Immutable.List();
|
||||
|
||||
if (status) {
|
||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
|
||||
status,
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
settings: state.get('local_settings'),
|
||||
askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const truncate = (str, num) => {
|
||||
const arr = Array.from(str);
|
||||
if (arr.length > num) {
|
||||
return arr.slice(0, num).join('') + '…';
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const titleFromStatus = (intl, status) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
const username = status.getIn(['account', 'username']);
|
||||
const user = displayName.trim().length === 0 ? username : displayName;
|
||||
const text = status.get('search_index');
|
||||
const attachmentCount = status.get('media_attachments').size;
|
||||
|
||||
return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount });
|
||||
};
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
isLoading: PropTypes.bool,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
ancestorsIds: ImmutablePropTypes.list.isRequired,
|
||||
descendantsIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
state = {
|
||||
fullscreen: false,
|
||||
isExpanded: undefined,
|
||||
threadExpanded: undefined,
|
||||
statusId: undefined,
|
||||
loadedStatusId: undefined,
|
||||
showMedia: undefined,
|
||||
revealBehindCW: undefined,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
attachFullscreenListener(this.onFullScreenChange);
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId));
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
let update = {};
|
||||
let updated = false;
|
||||
|
||||
if (props.params.statusId && state.statusId !== props.params.statusId) {
|
||||
props.dispatch(fetchStatus(props.params.statusId));
|
||||
update.threadExpanded = undefined;
|
||||
update.statusId = props.params.statusId;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']);
|
||||
if (revealBehindCW !== state.revealBehindCW) {
|
||||
update.revealBehindCW = revealBehindCW;
|
||||
if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (props.status && state.loadedStatusId !== props.status.get('id')) {
|
||||
update.showMedia = defaultMediaVisibility(props.status, props.settings);
|
||||
update.loadedStatusId = props.status.get('id');
|
||||
update.isExpanded = autoUnfoldCW(props.settings, props.status);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
return updated ? update : null;
|
||||
}
|
||||
|
||||
handleToggleHidden = () => {
|
||||
const { status } = this.props;
|
||||
|
||||
if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
|
||||
if (status.get('hidden')) {
|
||||
this.props.dispatch(revealStatus(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(hideStatus(status.get('id')));
|
||||
}
|
||||
} else if (this.props.status.get('spoiler_text')) {
|
||||
this.setExpansion(!this.state.isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
};
|
||||
|
||||
handleModalFavourite = (status) => {
|
||||
this.props.dispatch(favourite(status));
|
||||
};
|
||||
|
||||
handleFavouriteClick = (status, e) => {
|
||||
const { dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
if ((e && e.shiftKey) || !favouriteModal) {
|
||||
this.handleModalFavourite(status);
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'FAVOURITE',
|
||||
modalProps: {
|
||||
status,
|
||||
onFavourite: this.handleModalFavourite,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handlePin = (status) => {
|
||||
if (status.get('pinned')) {
|
||||
this.props.dispatch(unpin(status));
|
||||
} else {
|
||||
this.props.dispatch(pin(status));
|
||||
}
|
||||
};
|
||||
|
||||
handleReplyClick = (status) => {
|
||||
const { askReplyConfirmation, dispatch, intl } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
|
||||
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
dispatch(replyCompose(status, this.context.router.history));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleModalReblog = (status, privacy) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
dispatch(reblog(status, privacy));
|
||||
}
|
||||
};
|
||||
|
||||
handleReblogClick = (status, e) => {
|
||||
const { settings, dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
|
||||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this.handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleBookmarkClick = (status) => {
|
||||
if (status.get('bookmarked')) {
|
||||
this.props.dispatch(unbookmark(status));
|
||||
} else {
|
||||
this.props.dispatch(bookmark(status));
|
||||
}
|
||||
};
|
||||
|
||||
handleDeleteClick = (status, history, withRedraft = false) => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), history, withRedraft));
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
|
||||
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleEditClick = (status, history) => {
|
||||
this.props.dispatch(editStatus(status.get('id'), history));
|
||||
};
|
||||
|
||||
handleDirectClick = (account, router) => {
|
||||
this.props.dispatch(directCompose(account, router));
|
||||
};
|
||||
|
||||
handleMentionClick = (account, router) => {
|
||||
this.props.dispatch(mentionCompose(account, router));
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'MEDIA',
|
||||
modalProps: { statusId: this.props.status.get('id'), media, index, lang },
|
||||
}));
|
||||
};
|
||||
|
||||
handleOpenVideo = (media, lang, options) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'VIDEO',
|
||||
modalProps: { statusId: this.props.status.get('id'), media, lang, options },
|
||||
}));
|
||||
};
|
||||
|
||||
handleHotkeyOpenMedia = e => {
|
||||
const { status } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
|
||||
} else {
|
||||
this.handleOpenMedia(status.get('media_attachments'), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleMuteClick = (account) => {
|
||||
this.props.dispatch(initMuteModal(account));
|
||||
};
|
||||
|
||||
handleConversationMuteClick = (status) => {
|
||||
if (status.get('muted')) {
|
||||
this.props.dispatch(unmuteStatus(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(muteStatus(status.get('id')));
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleAll = () => {
|
||||
const { status, ancestorsIds, descendantsIds, settings } = this.props;
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||
let { isExpanded } = this.state;
|
||||
|
||||
if (settings.getIn(['content_warnings', 'shared_state']))
|
||||
isExpanded = !status.get('hidden');
|
||||
|
||||
if (!isExpanded) {
|
||||
this.props.dispatch(revealStatus(statusIds));
|
||||
} else {
|
||||
this.props.dispatch(hideStatus(statusIds));
|
||||
}
|
||||
|
||||
this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
|
||||
};
|
||||
|
||||
handleTranslate = status => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (status.get('translation')) {
|
||||
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
||||
} else {
|
||||
dispatch(translateStatus(status.get('id')));
|
||||
}
|
||||
};
|
||||
|
||||
handleBlockClick = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
const account = status.get('account');
|
||||
dispatch(initBlockModal(account));
|
||||
};
|
||||
|
||||
handleReport = (status) => {
|
||||
this.props.dispatch(initReport(status.get('account'), status));
|
||||
};
|
||||
|
||||
handleEmbed = (status) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
modalProps: { id: status.get('id') },
|
||||
}));
|
||||
};
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.handleMoveUp(this.props.status.get('id'));
|
||||
};
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.handleMoveDown(this.props.status.get('id'));
|
||||
};
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.handleReplyClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
this.handleFavouriteClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyBoost = () => {
|
||||
this.handleReblogClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyBookmark = () => {
|
||||
this.handleBookmarkClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.handleMentionClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1, true);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index, true);
|
||||
} else {
|
||||
this._selectChild(index - 1, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleMoveDown = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1, false);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||
} else {
|
||||
this._selectChild(index + 1, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node;
|
||||
const element = container.querySelectorAll('.focusable')[index];
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
renderChildren (list, ancestors) {
|
||||
const { params: { statusId } } = this.props;
|
||||
|
||||
return list.map((id, i) => (
|
||||
<StatusContainer
|
||||
key={id}
|
||||
id={id}
|
||||
expanded={this.state.threadExpanded}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 && list.get(i - 1)}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
setExpansion = value => {
|
||||
this.setState({ isExpanded: value });
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds, multiColumn } = this.props;
|
||||
|
||||
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.node?.querySelector('.detailed-status__wrapper')?.scrollIntoView(true);
|
||||
|
||||
// In the single-column interface, `scrollIntoView` will put the post behind the header,
|
||||
// so compensate for that.
|
||||
if (!multiColumn) {
|
||||
const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom;
|
||||
if (offset) {
|
||||
const scrollingElement = document.scrollingElement || document.body;
|
||||
scrollingElement.scrollBy(0, -offset);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
detachFullscreenListener(this.onFullScreenChange);
|
||||
}
|
||||
|
||||
onFullScreenChange = () => {
|
||||
this.setState({ fullscreen: isFullscreen() });
|
||||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
|
||||
);
|
||||
}
|
||||
|
||||
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
|
||||
|
||||
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
||||
}
|
||||
|
||||
if (descendantsIds && descendantsIds.size > 0) {
|
||||
descendants = <>{this.renderChildren(descendantsIds)}</>;
|
||||
}
|
||||
|
||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
bookmark: this.handleHotkeyBookmark,
|
||||
mention: this.handleHotkeyMention,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
toggleSpoiler: this.handleToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
};
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}>
|
||||
<ColumnHeader
|
||||
icon='comment'
|
||||
title={intl.formatMessage(messages.tootHeading)}
|
||||
onClick={this.handleHeaderClick}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
||||
{ancestors}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false, isExpanded)}>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
status={status}
|
||||
settings={settings}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
expanded={isExpanded}
|
||||
onToggleHidden={this.handleToggleHidden}
|
||||
onTranslate={this.handleTranslate}
|
||||
domain={domain}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
key={`action-bar-${status.get('id')}`}
|
||||
status={status}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMention={this.handleMentionClick}
|
||||
onMute={this.handleMuteClick}
|
||||
onMuteConversation={this.handleConversationMuteClick}
|
||||
onBlock={this.handleBlockClick}
|
||||
onReport={this.handleReport}
|
||||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromStatus(intl, status)}</title>
|
||||
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
|
||||
<link rel='canonical' href={status.get('url')} />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps)(Status));
|
|
@ -129,12 +129,12 @@ const mapStateToProps = state => ({
|
|||
const mapDispatchToProps = dispatch => ({
|
||||
/**
|
||||
* Set options in the redux store
|
||||
* @param opts
|
||||
* @param {Object} opts
|
||||
*/
|
||||
setOpt: (opts) => dispatch(doodleSet(opts)),
|
||||
/**
|
||||
* Submit doodle for upload
|
||||
* @param file
|
||||
* @param {File} file
|
||||
*/
|
||||
submit: (file) => dispatch(uploadCompose([file])),
|
||||
});
|
||||
|
@ -240,7 +240,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Key up handler
|
||||
* @param e
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
handleKeyUp = (e) => {
|
||||
if (e.target.nodeName === 'INPUT') return;
|
||||
|
@ -269,7 +269,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Key down handler
|
||||
* @param e
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
handleKeyDown = (e) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
|
@ -306,7 +306,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
/**
|
||||
* Set reference to the canvas element.
|
||||
* This is called during component init
|
||||
* @param elem - canvas element
|
||||
* @param {HTMLCanvasElement} elem - canvas element
|
||||
*/
|
||||
setCanvasRef = (elem) => {
|
||||
this.canvas = elem;
|
||||
|
@ -347,7 +347,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Set up the sketcher instance
|
||||
* @param canvas - canvas element. Null if we're just resizing
|
||||
* @param {HTMLCanvasElement | null} canvas - canvas element. Null if we're just resizing
|
||||
*/
|
||||
initSketcher (canvas = null) {
|
||||
const sizepreset = DOODLE_SIZES[this.size];
|
||||
|
@ -445,7 +445,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
/**
|
||||
* Palette left click.
|
||||
* Selects Fg color (or Bg, if Control/Meta is held)
|
||||
* @param e - event
|
||||
* @param {MouseEvent<HTMLButtonElement>} e - event
|
||||
*/
|
||||
onPaletteClick = (e) => {
|
||||
const c = e.target.dataset.color;
|
||||
|
@ -463,7 +463,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
/**
|
||||
* Palette right click.
|
||||
* Selects Bg color
|
||||
* @param e - event
|
||||
* @param {MouseEvent<HTMLButtonElement>} e - event
|
||||
*/
|
||||
onPaletteRClick = (e) => {
|
||||
this.bg = e.target.dataset.color;
|
||||
|
@ -473,7 +473,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Handle click on the Draw mode button
|
||||
* @param e - event
|
||||
* @param {MouseEvent<HTMLButtonElement>} e - event
|
||||
*/
|
||||
setModeDraw = (e) => {
|
||||
this.mode = 'draw';
|
||||
|
@ -482,7 +482,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Handle click on the Fill mode button
|
||||
* @param e - event
|
||||
* @param {MouseEvent<HTMLButtonElement>} e - event
|
||||
*/
|
||||
setModeFill = (e) => {
|
||||
this.mode = 'fill';
|
||||
|
@ -491,7 +491,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Handle click on Smooth checkbox
|
||||
* @param e - event
|
||||
* @param {ChangeEvent<HTMLInputElement>} e - event
|
||||
*/
|
||||
tglSmooth = (e) => {
|
||||
this.smoothing = !this.smoothing;
|
||||
|
@ -500,7 +500,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Handle click on Adaptive checkbox
|
||||
* @param e - event
|
||||
* @param {ChangeEvent<HTMLInputElement>} e - event
|
||||
*/
|
||||
tglAdaptive = (e) => {
|
||||
this.adaptiveStroke = !this.adaptiveStroke;
|
||||
|
@ -509,7 +509,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Handle change of the Weight input field
|
||||
* @param e - event
|
||||
* @param {ChangeEvent<HTMLInputElement>} e - event
|
||||
*/
|
||||
setWeight = (e) => {
|
||||
this.weight = +e.target.value || 1;
|
||||
|
@ -517,7 +517,7 @@ class DoodleModal extends ImmutablePureComponent {
|
|||
|
||||
/**
|
||||
* Set size - clalback from the select box
|
||||
* @param e - event
|
||||
* @param {ChangeEvent<HTMLSelectElement>} e - event
|
||||
*/
|
||||
changeSize = (e) => {
|
||||
let newSize = e.target.value;
|
||||
|
|
|
@ -21,7 +21,7 @@ const messages = defineMessages({
|
|||
explore: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
|
@ -55,12 +55,12 @@ class NavigationPanel extends Component {
|
|||
return (
|
||||
<div className='navigation-panel'>
|
||||
{transientSingleColumn && (
|
||||
<>
|
||||
<div className='navigation-panel__logo'>
|
||||
<a href={`/deck${location.pathname}`} className='button button--block'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
<hr />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
|
|
|
@ -34,7 +34,7 @@ const SignInBanner = () => {
|
|||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favorite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
||||
{signupButton}
|
||||
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||
</div>
|
||||
|
|
|
@ -191,6 +191,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
|
||||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
|
||||
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
|
||||
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||
|
@ -528,11 +529,12 @@ class UI extends Component {
|
|||
};
|
||||
|
||||
handleHotkeyBack = () => {
|
||||
// if history is exhausted, or we would leave mastodon, just go to root.
|
||||
if (window.history.state) {
|
||||
this.props.history.goBack();
|
||||
const { history } = this.props;
|
||||
|
||||
if (history.location?.state?.fromMastodon) {
|
||||
history.goBack();
|
||||
} else {
|
||||
this.props.history.push('/');
|
||||
history.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -577,7 +577,10 @@ class Video extends PureComponent {
|
|||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
{
|
||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||
"follow_recommendations.done": "Done",
|
||||
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
|
||||
"follow_recommendations.lead": "Plasings van mense wat jy volg, kom chronologies in jou tuisvoer verby. Moenie huiwer nie. Volg na hartelus. As daar mense is wie se plasings jy nie meer wil sien nie, ontvolg hulle net!",
|
||||
"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_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}.",
|
||||
|
|
|
@ -1,10 +1,93 @@
|
|||
{
|
||||
"about.fork_disclaimer": "جلتش-سوك هو برنامج حر مفتوح المصدر متفرع عن ماستدون.",
|
||||
"account.add_account_note": "إضافة ملاحظة لـ @{name}",
|
||||
"account.disclaimer_full": "قد لا تعكِس المعلومات أدناه كامل الملف الشخصي للمستخدِم.",
|
||||
"account.follows": "يتابِع",
|
||||
"account.joined": "إنضم بتاريخ {date}",
|
||||
"account.suspended_disclaimer_full": "تم تعليق هذا المستخدم من قبل المشرف.",
|
||||
"account.view_full_profile": "عرض الملف الشخصي كاملاً",
|
||||
"account_note.cancel": "إلغاء",
|
||||
"account_note.edit": "تعديل",
|
||||
"account_note.glitch_placeholder": "لم يُقدّم أي تعليق",
|
||||
"account_note.save": "حفظ",
|
||||
"advanced_options.icon_title": "خيارات متقدمة",
|
||||
"advanced_options.local-only.long": "لا تنشر في خوادم أخرى",
|
||||
"advanced_options.local-only.short": "المحلي فقط",
|
||||
"advanced_options.local-only.tooltip": "هذا المنشور محلي فقط",
|
||||
"advanced_options.threaded_mode.long": "تلقائياً يفتح رداً عند النشر",
|
||||
"advanced_options.threaded_mode.short": "وضعُ النقاش الخيطي",
|
||||
"advanced_options.threaded_mode.tooltip": "تم تمكين وضع النقاش الخيطي",
|
||||
"boost_modal.missing_description": "هذا المنشور يحتوي على وسائط بلا وصف",
|
||||
"column.favourited_by": "المفضلة من قبل",
|
||||
"column.heading": "متنوعة",
|
||||
"column.reblogged_by": "المرقى من قبل",
|
||||
"column.subheading": "خيارات متنوعة",
|
||||
"column_header.profile": "الملف الشخصي",
|
||||
"column_subheading.lists": "القوائم",
|
||||
"column_subheading.navigation": "التنقل",
|
||||
"community.column_settings.allow_local_only": "إظهار المنشورات المحلية فقط",
|
||||
"compose.attach": "أرفق...",
|
||||
"compose.attach.doodle": "ارسم شيئاً",
|
||||
"compose.attach.upload": "رفع ملف",
|
||||
"compose.content-type.html": "HTML",
|
||||
"compose.content-type.markdown": "ماركداون",
|
||||
"compose.content-type.plain": "نص عادي",
|
||||
"compose_form.poll.multiple_choices": "السماح بخيارات متعددة",
|
||||
"compose_form.poll.single_choice": "السماح بخيار واحد",
|
||||
"compose_form.spoiler": "إخفاء النص خلف تحذير",
|
||||
"confirmation_modal.do_not_ask_again": "لا تطلب التأكيد مرة أخرى",
|
||||
"confirmations.deprecated_settings.confirm": "استخدام تفضيلات ماستدون",
|
||||
"confirmations.missing_media_description.confirm": "أرسل على أيّة حال",
|
||||
"confirmations.missing_media_description.edit": "تعديل الوسائط",
|
||||
"confirmations.unfilter.author": "المؤلف",
|
||||
"confirmations.unfilter.confirm": "عرض",
|
||||
"confirmations.unfilter.edit_filter": "تعديل عامل التصفية",
|
||||
"content-type.change": "نوع المحتوى",
|
||||
"direct.group_by_conversations": "تجميع حسب المحادثة",
|
||||
"empty_column.follow_recommendations": "يبدو أنه لا يمكن إنشاء أي اقتراحات لك. يمكنك البحث عن أشخاص قد تعرفهم أو استكشاف الوسوم الرائجة.",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة",
|
||||
"favourite_modal.combo": "يُمكنك الضّغط على {combo} لتخطي هذا في المرة المُقبلة",
|
||||
"follow_recommendations.done": "تم",
|
||||
"follow_recommendations.heading": "تابع الأشخاص الذين ترغب في رؤية منشوراتهم! إليك بعض الاقتراحات.",
|
||||
"follow_recommendations.lead": "ستظهر منشورات الأشخاص الذين تُتابعتهم بترتيب تسلسلي زمني على صفحتك الرئيسية. لا تخف إذا ارتكبت أي أخطاء، تستطيع إلغاء متابعة أي شخص في أي وقت تريد!",
|
||||
"getting_started.onboarding": "خذني في جولة",
|
||||
"home.column_settings.advanced": "متقدم",
|
||||
"home.column_settings.filter_regex": "إزالة باستخدام التعبيرات النمطية",
|
||||
"home.settings": "إعدادات العمود",
|
||||
"keyboard_shortcuts.bookmark": "لإضافة علامة",
|
||||
"keyboard_shortcuts.secondary_toot": "لإرسال التبويق باستخدام إعدادات الخصوصية الثانوية",
|
||||
"keyboard_shortcuts.toggle_collapse": "لطي/فتح التبويقات",
|
||||
"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_apply": "مسح المحدد",
|
||||
"notification_purge.btn_invert": "عكس التحديد",
|
||||
"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_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.appetoot": "نشراً طيباً!",
|
||||
"onboarding.page_six.apps_available": "هناك {apps} متوفرة على iOS و أندرويد و منصات أخرى.",
|
||||
"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": "الرجاء قراءة {guidelines} من {domain}!",
|
||||
"onboarding.skip": "تخطي",
|
||||
"settings.close": "إغلاق",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
"settings.preferences": "Preferences"
|
||||
"settings.preferences": "Preferences",
|
||||
"web_app_crash.settings": "الإعدادات",
|
||||
"web_app_crash.title": "نحن آسفون، لقد حدث خطأ ما في تطبيق ماستدون."
|
||||
}
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
{
|
||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||
"follow_recommendations.done": "সম্পন্ন",
|
||||
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
|
||||
"follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
|
||||
"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_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}.",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
{
|
||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||
"follow_recommendations.done": "Done",
|
||||
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
|
||||
"follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
|
||||
"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_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}.",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
|
|
|
@ -32,10 +32,6 @@
|
|||
"keyboard_shortcuts.bookmark": "Přidat do záložek",
|
||||
"keyboard_shortcuts.secondary_toot": "Odeslat příspěvek s druhotným nastavením soukromí",
|
||||
"keyboard_shortcuts.toggle_collapse": "Sbalit/rozbalit příspěvek",
|
||||
"layout.auto": "Automatické",
|
||||
"layout.hint.auto": "Vybrat rozložení automaticky v závislosti na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
|
||||
"layout.hint.desktop": "Použít vícesloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
|
||||
"layout.hint.single": "Použít jednosloupcové rozložení nezávisle na nastavení “Povolit pokročilé webové rozhraní” a velikosti obrazovky.",
|
||||
"media_gallery.sensitive": "Citlivý obsah",
|
||||
"navigation_bar.app_settings": "Nastavení aplikace",
|
||||
"navigation_bar.featured_users": "Vybraní uživatelé",
|
||||
|
@ -100,7 +96,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Pokud jsou k příspěvku přiložena média, použije se první z nich jako pozadí",
|
||||
"settings.image_backgrounds_users": "Nastavit sbaleným příspěvkům obrázkové pozadí",
|
||||
"settings.inline_preview_cards": "Zobrazit v časové ose náhledy externích odkazů",
|
||||
"settings.layout": "Rozložení:",
|
||||
"settings.layout_opts": "Možnosti rozvržení",
|
||||
"settings.media": "Média",
|
||||
"settings.media_fullwidth": "Zobrazit náhledy v plné šířce",
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"favourite_modal.combo": "Mit {combo} wird dieses Fenster beim nächsten Mal nicht mehr angezeigt",
|
||||
"follow_recommendations.done": "Fertig",
|
||||
"follow_recommendations.heading": "Folge Leuten, deren Beiträge du sehen möchtest! Hier sind einige Vorschläge.",
|
||||
"follow_recommendations.lead": "Beiträge von Profilen, denen du folgst, werden in chronologischer Reihenfolge auf deiner Startseite angezeigt. Sei unbesorgt, mal Fehler zu begehen. Du kannst diesen Konten ganz einfach und jederzeit wieder entfolgen.",
|
||||
"follow_recommendations.lead": "Beiträge von Leuten, denen du folgst, werden in chronologischer Reihenfolge auf deiner Startseite angezeigt. Sei unbesorgt, mal Fehler zu begehen. Du kannst Leuten jederzeit ganz einfach wieder entfolgen!",
|
||||
"getting_started.onboarding": "Führe mich herum",
|
||||
"home.column_settings.advanced": "Erweitert",
|
||||
"home.column_settings.filter_regex": "Mit regulären Ausdrücken herausfiltern",
|
||||
|
@ -62,12 +62,6 @@
|
|||
"keyboard_shortcuts.secondary_toot": "Toot mit sekundärer Privatsphäreeinstellung absenden",
|
||||
"keyboard_shortcuts.toggle_collapse": "Toots ein-/ausklappen",
|
||||
"tooltips.reactions": "Reaktionen",
|
||||
"layout.auto": "Automatisch",
|
||||
"layout.desktop": "Desktop",
|
||||
"layout.hint.auto": "Automatisch das Layout anhand der Einstellung \"Erweitertes Webinterface verwenden\" und Bildschirmgröße auswählen.",
|
||||
"layout.hint.desktop": "Das mehrspaltige Layout verwenden, unabhängig von der Einstellung \"Erweitertes Webinterface verwenden\".",
|
||||
"layout.hint.single": "Das einspaltige Layout verwenden, unabhängig von der Einstellung \"Erweitertes Webinterface verwenden\".",
|
||||
"layout.single": "Mobil",
|
||||
"media_gallery.sensitive": "Empfindlich",
|
||||
"moved_to_warning": "Dieses Konto ist als verschoben zu {moved_to_link} markiert und akzeptiert daher keine neuen Follower.",
|
||||
"navigation_bar.app_settings": "App-Einstellungen",
|
||||
|
@ -104,12 +98,6 @@
|
|||
"onboarding.page_three.search": "Benutze die Suchleiste, um Leute zu finden und Hashtags anzusehen, wie etwa {illustration} und {introductions}. Um nach einer Person zu suchen, die nicht auf dieser Instanz ist, benutze deren vollständigen Nutzername.",
|
||||
"onboarding.page_two.compose": "Schreibe Posts in der Verfassen-Spalte. Mit den Symbolen unten kannst du Bilder hochladen, Privatsphäre-Einstellungen ändern, und Inhaltswarnungen hinzufügen.",
|
||||
"onboarding.skip": "Überspringen",
|
||||
"search_popout.search_format": "Erweitertes Suchformat",
|
||||
"search_popout.tips.full_text": "Simple Suchanfragen geben sowohl Beiträge, die du geschrieben, favorisiert oder geteilt hast oder in denen du erwähnt wurdest, als auch passende Nutzernamen, Anzeigenamen oder Hashtags, zurück.",
|
||||
"search_popout.tips.hashtag": "Hashtag",
|
||||
"search_popout.tips.status": "Beitrag",
|
||||
"search_popout.tips.text": "Simple Suchanfragen geben passende Nutzernamen, Anzeigenamen oder Hashtags zurück",
|
||||
"search_popout.tips.user": "Nutzer",
|
||||
"settings.always_show_spoilers_field": "Das Inhaltswarnungs-Feld immer aktivieren",
|
||||
"settings.auto_collapse": "Automatisches Einklappen",
|
||||
"settings.auto_collapse_all": "Alles",
|
||||
|
@ -146,7 +134,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Wenn der Post Anhänge hat, wird der erste als Hintergrund verwendet",
|
||||
"settings.image_backgrounds_users": "Eingeklappten Toots einen Bild-Hintergrund geben",
|
||||
"settings.inline_preview_cards": "Eingebettete Vorschaukarten für externe Links",
|
||||
"settings.layout": "Layout:",
|
||||
"settings.layout_opts": "Layout-Optionen",
|
||||
"settings.media": "Medien",
|
||||
"settings.media_fullwidth": "Medienvorschau in voller Breite",
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
{
|
||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||
"follow_recommendations.done": "Done",
|
||||
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
|
||||
"follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
|
||||
"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_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}.",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
|
|
|
@ -102,12 +102,6 @@
|
|||
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
|
||||
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
|
||||
"onboarding.skip": "Skip",
|
||||
"search_popout.search_format": "Advanced search format",
|
||||
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
|
||||
"search_popout.tips.hashtag": "hashtag",
|
||||
"search_popout.tips.status": "status",
|
||||
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
|
||||
"search_popout.tips.user": "user",
|
||||
"settings.always_show_spoilers_field": "Always enable the Content Warning field",
|
||||
"settings.auto_collapse": "Automatic collapsing",
|
||||
"settings.auto_collapse_all": "Everything",
|
||||
|
|
|
@ -14,10 +14,12 @@
|
|||
"advanced_options.local-only.long": "Ne afiŝi al aliaj instancoj",
|
||||
"advanced_options.local-only.short": "Nur loka",
|
||||
"advanced_options.local-only.tooltip": "Ĉi tiu afiŝo estas nur-loka",
|
||||
"advanced_options.threaded_mode.long": "Aŭtomate komencas respondon post afiŝado",
|
||||
"advanced_options.threaded_mode.short": "Fadena reĝimo",
|
||||
"advanced_options.threaded_mode.tooltip": "Fadena reĝimo ŝaltita",
|
||||
"boost_modal.missing_description": "Ĉi tiu afiŝo enhavas plurmedion, ke ne havas priskribon",
|
||||
"column.favourited_by": "Stelumita per",
|
||||
"column.heading": "Diversaj aferoj",
|
||||
"column.reblogged_by": "Diskonigita de",
|
||||
"column.subheading": "Diversaj agordoj",
|
||||
"column_header.profile": "Profilo",
|
||||
|
@ -42,10 +44,13 @@
|
|||
"confirmations.unfilter.author": "Aŭtoro",
|
||||
"confirmations.unfilter.confirm": "Montri",
|
||||
"confirmations.unfilter.edit_filter": "Redakti filtrilon",
|
||||
"confirmations.unfilter.filters": "{count, plural, one {# filtrilo} other {# filtriloj}} kongruas",
|
||||
"content-type.change": "Tipo de enhavo",
|
||||
"empty_column.follow_recommendations": "Ŝajnas, ke neniuj sugestoj povis esti generitaj por vi. Vi povas provi uzi serĉon por serĉi homojn, kiujn vi eble konas, aŭ esplori tendencajn kradvortojn.",
|
||||
"follow_recommendations.done": "Farita",
|
||||
"follow_recommendations.heading": "Sekvi la personojn kies mesaĝojn vi volas vidi! Jen iom da sugestoj.",
|
||||
"follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, aperos laŭ kronologia ordo en via hejma templinio. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!",
|
||||
"follow_recommendations.lead": "La mesaĝoj de personoj, kiujn vi sekvas, aperos laŭ kronologia ordo en via hejma templinio. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!",
|
||||
"home.column_settings.filter_regex": "Filtri per regulaj esprimoj",
|
||||
"navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
|
||||
"notification_purge.btn_all": "Selekti ĉiujn",
|
||||
"notification_purge.btn_apply": "Forigi selektajn",
|
||||
|
@ -65,10 +70,6 @@
|
|||
"onboarding.page_six.various_app": "poŝtelefonaj aplikaĵoj",
|
||||
"onboarding.page_three.profile": "Redakti vian profilon por ŝanĝi vian profilbildon, biografion kaj montro-nomon. Vi povas ankaŭ trovi aliajn agordojn tie.",
|
||||
"onboarding.skip": "Preterlasi",
|
||||
"search_popout.search_format": "Detala serĉformato",
|
||||
"search_popout.tips.hashtag": "kradvorto",
|
||||
"search_popout.tips.text": "Simpla teksta serĉo montras la kongruajn afiŝitajn nomojn, uzantnomojn kaj kradvortojn",
|
||||
"search_popout.tips.user": "uzanto",
|
||||
"settings.auto_collapse_all": "Ĉiuj",
|
||||
"settings.auto_collapse_lengthy": "Longaj afiŝoj",
|
||||
"settings.auto_collapse_media": "Afiŝoj kun aŭdovidaĵoj",
|
||||
|
@ -76,8 +77,12 @@
|
|||
"settings.auto_collapse_reblogs": "Diskonigoj",
|
||||
"settings.auto_collapse_replies": "Respondoj",
|
||||
"settings.close": "Fermi",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
"settings.content_warnings": "Enhavaj avertoj",
|
||||
"settings.content_warnings.regexp": "Regula esprimo",
|
||||
"settings.content_warnings_filter": "Enhavaj avertoj, kiujn ne aŭtomate malkaŝu:",
|
||||
"settings.content_warnings_media_outside": "Montri plurmediojn ekstere de enhavaj avertoj",
|
||||
"settings.content_warnings_media_outside_hint": "Fari same, kiel la originala programaro Mastodon, por ke la enhavaj avertoj ne influas plurmediojn",
|
||||
"settings.image_backgrounds_media_hint": "Se la afiŝo havas plurmediojn, uzi la unuan kiel fono",
|
||||
"settings.preferences": "Preferences",
|
||||
"settings.shared_settings_link": "preferoj de uzanto",
|
||||
"settings.side_arm": "Duaranga butono por afiŝi:",
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
"account.disclaimer_full": "La información aquí presentada puede reflejar de manera incompleta el perfil del usuario.",
|
||||
"account.follows": "Sigue",
|
||||
"account.joined": "Unido el {date}",
|
||||
"account.mute_notifications": "Silenciar notificaciones de @{name}",
|
||||
"account.suspended_disclaimer_full": "Este usuario ha sido suspendido por un moderador.",
|
||||
"account.unmute_notifications": "Dejar de silenciar notificaciones de @{name}",
|
||||
"account.view_full_profile": "Ver perfil completo",
|
||||
"account_note.cancel": "Cancelar",
|
||||
"account_note.edit": "Editar",
|
||||
|
@ -50,6 +52,7 @@
|
|||
"empty_column.follow_recommendations": "Parece que no se pudieron generar sugerencias para vos. Podés intentar buscar gente que conozcas o explorar las tendencias de las etiquetas.",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
||||
"favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
|
||||
"firehose.column_settings.allow_local_only": "Mostrar sólo mensajes locales en \"Todos\"",
|
||||
"follow_recommendations.done": "Listo",
|
||||
"follow_recommendations.heading": "¡Seguí cuentas cuyos mensajes te gustaría ver! Acá tenés algunas sugerencias.",
|
||||
"follow_recommendations.lead": "Los mensajes de las cuentas que seguís aparecerán en orden cronológico en la columna \"Inicio\". No tengás miedo de meter la pata, ¡podés dejar de seguir cuentas fácilmente en cualquier momento!",
|
||||
|
@ -61,12 +64,6 @@
|
|||
"keyboard_shortcuts.bookmark": "a marcadores",
|
||||
"keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
|
||||
"keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
|
||||
"layout.auto": "Automático",
|
||||
"layout.desktop": "Escritorio",
|
||||
"layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y tamaño de pantalla",
|
||||
"layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
|
||||
"layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
|
||||
"layout.single": "Móvil",
|
||||
"media_gallery.sensitive": "Sensible",
|
||||
"moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
|
||||
"navigation_bar.app_settings": "Ajustes de aplicación",
|
||||
|
@ -136,7 +133,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
|
||||
"settings.image_backgrounds_users": "Darle fondo de imagen a toots colapsados",
|
||||
"settings.inline_preview_cards": "Vista previa para enlaces externos",
|
||||
"settings.layout": "Diseño",
|
||||
"settings.layout_opts": "Opciones de diseño",
|
||||
"settings.media": "Medios",
|
||||
"settings.media_fullwidth": "Ancho completo al mostrar medios ",
|
||||
|
|
|
@ -61,12 +61,6 @@
|
|||
"keyboard_shortcuts.bookmark": "a marcadores",
|
||||
"keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
|
||||
"keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
|
||||
"layout.auto": "Automático",
|
||||
"layout.desktop": "Escritorio",
|
||||
"layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y tamaño de pantalla",
|
||||
"layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
|
||||
"layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o tamaño de pantalla",
|
||||
"layout.single": "Móvil",
|
||||
"media_gallery.sensitive": "Sensible",
|
||||
"moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
|
||||
"navigation_bar.app_settings": "Ajustes de aplicación",
|
||||
|
@ -136,7 +130,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
|
||||
"settings.image_backgrounds_users": "Darle fondo de imagen a toots colapsados",
|
||||
"settings.inline_preview_cards": "Vista previa para enlaces externos",
|
||||
"settings.layout": "Diseño",
|
||||
"settings.layout_opts": "Opciones de diseño",
|
||||
"settings.media": "Medios",
|
||||
"settings.media_fullwidth": "Ancho completo al mostrar medios ",
|
||||
|
|
|
@ -61,12 +61,6 @@
|
|||
"keyboard_shortcuts.bookmark": "a marcadores",
|
||||
"keyboard_shortcuts.secondary_toot": "para enviar un toot usando lac onfiguración de privacidad secundaria",
|
||||
"keyboard_shortcuts.toggle_collapse": "para colapsar/descolapsar toots",
|
||||
"layout.auto": "Automático",
|
||||
"layout.desktop": "Escritorio",
|
||||
"layout.hint.auto": "Seleccionar un diseño automáticamente basado en \"Habilitar interface web avanzada\" y el tamaño de la pantalla.",
|
||||
"layout.hint.desktop": "Utiliza el diseño multi-columna sin importar \"Habilitar interface web avanzada\" o el tamaño de la pantalla.",
|
||||
"layout.hint.single": "Utiliza el diseño de una columna sin importar \"Habilitar interface web avanzada\" o el tamaño de la pantalla.",
|
||||
"layout.single": "Móvil",
|
||||
"media_gallery.sensitive": "Sensible",
|
||||
"moved_to_warning": "Esta cuenta está marcada como movida a {moved_to_link}, y por lo tanto no aceptará nuevos seguimientos.",
|
||||
"navigation_bar.app_settings": "Ajustes de la aplicación",
|
||||
|
@ -136,7 +130,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Si la publicación tiene algún archivo adjunto, utilice el primero como fondo",
|
||||
"settings.image_backgrounds_users": "Darle fondo de imagen a publicaciones colapsadas",
|
||||
"settings.inline_preview_cards": "Vista previa para enlaces externos",
|
||||
"settings.layout": "Diseño",
|
||||
"settings.layout_opts": "Opciones de diseño",
|
||||
"settings.media": "Medios",
|
||||
"settings.media_fullwidth": "Ancho completo al mostrar medios ",
|
||||
|
|
|
@ -1,10 +1,57 @@
|
|||
{
|
||||
"about.fork_disclaimer": "Glitch-Goc یک نرمافزار آزاد است که از Mastodon انشعاب گرفته است.",
|
||||
"account.add_account_note": "افزودن یادداشت برای @{name}",
|
||||
"account.disclaimer_full": "اطلاعات زیر ممکن است نمایه کاربر را کامل منعکس نکند.",
|
||||
"account.joined": "در {date} پیوست",
|
||||
"account.view_full_profile": "مشاهده کامل نمایه",
|
||||
"account_note.cancel": "لغو",
|
||||
"account_note.edit": "ویرایش",
|
||||
"account_note.save": "ذخیره",
|
||||
"advanced_options.icon_title": "گزینههای پیشرفته",
|
||||
"advanced_options.local-only.short": "فقط محلی",
|
||||
"advanced_options.local-only.tooltip": "این فرسته فقط محلی است",
|
||||
"column_header.profile": "نمایه",
|
||||
"compose.attach.upload": "بارگذاری پرونده",
|
||||
"compose.content-type.html": "HTML",
|
||||
"compose.content-type.markdown": "مارکدون",
|
||||
"compose.content-type.plain": "متن ساده",
|
||||
"confirmations.missing_media_description.edit": "ویرایش رسانه",
|
||||
"confirmations.unfilter.author": "نویسنده",
|
||||
"confirmations.unfilter.confirm": "نمایش",
|
||||
"confirmations.unfilter.edit_filter": "ویرایش پالایه",
|
||||
"content-type.change": "نوع محتوا",
|
||||
"empty_column.follow_recommendations": "به نظر نمیتوان هیچ پیشنهادی برایتان ایجاد کرد. میتوانید برای یافتن افرادی که ممکن است بشناسید از جستوجو یا کاوش برچسبهای داغ استفاده کنید.",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "حسابهای پیشنهاد شده",
|
||||
"follow_recommendations.done": "انجام شد",
|
||||
"follow_recommendations.heading": "افرادی را که میخواهید فرستههایشان را ببینید پیگیری کنید! اینها تعدادی پیشنهاد هستند.",
|
||||
"follow_recommendations.lead": "فرستههای افرادی که دنبال میکنید به ترتیب زمانی در خوراک خانهتان نشان داده خواهد شد. از اشتباه کردن نترسید. میتوانید به همین سادگی در هر زمانی از دنبال کردن افراد دست بکشید!",
|
||||
"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.",
|
||||
"home.column_settings.advanced": "پیشرفته",
|
||||
"navigation_bar.app_settings": "تنظیمات کاره",
|
||||
"navigation_bar.featured_users": "کاربران پیشنهاد شده",
|
||||
"navigation_bar.keyboard_shortcuts": "میانبرهای صفحهکلید",
|
||||
"notification.markForDeletion": "علامتگذاری برای حذف",
|
||||
"notification_purge.btn_all": "انتخاب همه",
|
||||
"onboarding.done": "انجام شد",
|
||||
"onboarding.page_one.federation": "{domain} یک \"نمونه\" از ماستودون است. ماستودون شبکه ای از کارسازهای مستقل است که برای ایجاد یک شبکه اجتماعی بزرگتر به هم می پیوندند. ما این کارسازها را نمونه می نامیم.",
|
||||
"onboarding.page_one.welcome": "به {domain} خوش آمدید!",
|
||||
"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}.",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
"settings.preferences": "Preferences"
|
||||
"onboarding.page_six.various_app": "کارههای موبایل",
|
||||
"settings.auto_collapse_reblogs": "تقویتها",
|
||||
"settings.auto_collapse_replies": "پاسخها",
|
||||
"settings.close": "بستن",
|
||||
"settings.content_warnings": "هشدارهای محتوا",
|
||||
"settings.media": "رسانه",
|
||||
"settings.pop_in_left": "چپ",
|
||||
"settings.pop_in_right": "راست",
|
||||
"settings.preferences": "ترجیحات کاربر",
|
||||
"settings.shared_settings_link": "ترجیحات کاربر",
|
||||
"settings.side_arm.none": "هیچ یک",
|
||||
"settings.status_icons": "نقشکهای توت",
|
||||
"status.in_reply_to": "این توت یک پاسخ است",
|
||||
"status.is_poll": "این توت یک نظرسنجی است",
|
||||
"status.sensitive_toggle": "برای مشاهده کلیک کنید",
|
||||
"web_app_crash.debug_info": "اطلاعات اشکالزدایی",
|
||||
"web_app_crash.reload": "نوسازی",
|
||||
"web_app_crash.settings": "تنظیمات",
|
||||
"web_app_crash.title": "متأسفیم، اما مشکلی در کاره ماستودون رخ داد."
|
||||
}
|
||||
|
|
|
@ -61,12 +61,6 @@
|
|||
"keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
|
||||
"keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
|
||||
"keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
|
||||
"layout.auto": "Auto",
|
||||
"layout.desktop": "Ordinateur",
|
||||
"layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
|
||||
"layout.hint.desktop": "Utiliser la mise en page en plusieurs colonnes indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
|
||||
"layout.hint.single": "Utiliser la mise en page à colonne unique indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
|
||||
"layout.single": "Téléphone",
|
||||
"media_gallery.sensitive": "Sensible",
|
||||
"moved_to_warning": "Ce compte a déménagé vers {moved_to_link} et ne peut donc plus accepter de nouveaux abonné·e·s.",
|
||||
"navigation_bar.app_settings": "Paramètres de l'application",
|
||||
|
@ -135,7 +129,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Si le post a un média attaché, utiliser le premier comme arrière-plan du post",
|
||||
"settings.image_backgrounds_users": "Donner aux posts repliés une image en arrière-plan",
|
||||
"settings.inline_preview_cards": "Cartes d'aperçu pour les liens externes",
|
||||
"settings.layout": "Mise en page :",
|
||||
"settings.layout_opts": "Mise en page",
|
||||
"settings.media": "Média",
|
||||
"settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
|
||||
|
|
|
@ -62,12 +62,6 @@
|
|||
"keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
|
||||
"keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
|
||||
"tooltips.reactions": "Réactions",
|
||||
"layout.auto": "Auto",
|
||||
"layout.desktop": "Ordinateur",
|
||||
"layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
|
||||
"layout.hint.desktop": "Utiliser la mise en page en plusieurs colonnes indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
|
||||
"layout.hint.single": "Utiliser la mise en page à colonne unique indépendamment de l'option \"Activer l'interface Web avancée\" ou de la taille d'écran.",
|
||||
"layout.single": "Téléphone",
|
||||
"media_gallery.sensitive": "Sensible",
|
||||
"moved_to_warning": "Ce compte a déménagé vers {moved_to_link} et ne peut donc plus accepter de nouveaux abonné·e·s.",
|
||||
"navigation_bar.app_settings": "Paramètres de l'application",
|
||||
|
@ -139,7 +133,6 @@
|
|||
"settings.image_backgrounds_media_hint": "Si le post a un média attaché, utiliser le premier comme arrière-plan du post",
|
||||
"settings.image_backgrounds_users": "Donner aux posts repliés une image en arrière-plan",
|
||||
"settings.inline_preview_cards": "Cartes d'aperçu pour les liens externes",
|
||||
"settings.layout": "Mise en page :",
|
||||
"settings.layout_opts": "Mise en page",
|
||||
"settings.media": "Média",
|
||||
"settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
{
|
||||
"empty_column.follow_recommendations": "Is cosúil nár fhéadfaí moltaí a ghineadh. D'fhéadfá cuardach a úsáid le teacht ar dhaoine a bhfuil aithne agat orthu, nó iniúchadh ar haischlibeanna atá ag treochtáil a dhéanamh.",
|
||||
"follow_recommendations.done": "Déanta",
|
||||
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
|
||||
"follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
|
||||
"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_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}.",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue