mirror of
https://github.com/funamitech/mastodon
synced 2024-11-27 14:29:03 +09:00
Merge branch 'main' of https://github.com/glitch-soc/mastodon
This commit is contained in:
commit
d79d7e2215
@ -4,10 +4,6 @@ FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye
|
||||
# Install Rails
|
||||
# RUN gem install rails webdrivers
|
||||
|
||||
# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
|
||||
# The value is a comma-separated list of allowed domains
|
||||
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"
|
||||
|
||||
ARG NODE_VERSION="16"
|
||||
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"
|
||||
|
||||
|
49
.devcontainer/codespaces/devcontainer.json
Normal file
49
.devcontainer/codespaces/devcontainer.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Mastodon on GitHub Codespaces",
|
||||
"dockerComposeFile": "../docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
|
||||
"runServices": ["app", "db", "redis"],
|
||||
|
||||
"forwardPorts": [3000, 4000],
|
||||
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "web",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4000": {
|
||||
"label": "stream",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
|
||||
"otherPortsAttributes": {
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
|
||||
"remoteEnv": {
|
||||
"LOCAL_DOMAIN": "${localEnv:CODESPACE_NAME}-3000.app.github.dev",
|
||||
"LOCAL_HTTPS": "true",
|
||||
"STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev",
|
||||
"DISABLE_FORGERY_REQUEST_PROTECTION": "true",
|
||||
"ES_ENABLED": "",
|
||||
"LIBRE_TRANSLATE_ENDPOINT": ""
|
||||
},
|
||||
|
||||
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
|
||||
"postCreateCommand": ".devcontainer/post-create.sh",
|
||||
"waitFor": "postCreateCommand",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Mastodon",
|
||||
"name": "Mastodon on local machine",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
@ -8,13 +8,23 @@
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
|
||||
"runServices": ["app", "db", "redis"],
|
||||
|
||||
"forwardPorts": [3000, 4000],
|
||||
|
||||
"containerEnv": {
|
||||
"ES_ENABLED": "",
|
||||
"LIBRE_TRANSLATE_ENDPOINT": ""
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "web",
|
||||
"onAutoForward": "notify",
|
||||
"requireLocalPort": true
|
||||
},
|
||||
"4000": {
|
||||
"label": "stream",
|
||||
"onAutoForward": "silent",
|
||||
"requireLocalPort": true
|
||||
}
|
||||
},
|
||||
|
||||
"otherPortsAttributes": {
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
|
||||
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
|
||||
|
@ -25,6 +25,7 @@ services:
|
||||
command: sleep infinity
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000'
|
||||
- '127.0.0.1:3035:3035'
|
||||
- '127.0.0.1:4000:4000'
|
||||
networks:
|
||||
- external_network
|
||||
|
10
.github/workflows/build-container-image.yml
vendored
10
.github/workflows/build-container-image.yml
vendored
@ -8,7 +8,9 @@ on:
|
||||
type: boolean
|
||||
push_to_images:
|
||||
type: string
|
||||
version_suffix:
|
||||
version_prerelease:
|
||||
type: string
|
||||
version_metadata:
|
||||
type: string
|
||||
flavor:
|
||||
type: string
|
||||
@ -74,8 +76,6 @@ jobs:
|
||||
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 }}
|
||||
@ -83,7 +83,9 @@ jobs:
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
build-args: MASTODON_VERSION_SUFFIX=${{ inputs.version_suffix }}
|
||||
build-args: |
|
||||
MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }}
|
||||
MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}
|
||||
|
6
.github/workflows/build-push-pr.yml
vendored
6
.github/workflows/build-push-pr.yml
vendored
@ -21,9 +21,9 @@ jobs:
|
||||
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
|
||||
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
|
||||
metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}
|
||||
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
use_native_arm64_builder: false
|
||||
push_to_images: |
|
||||
ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
version_suffix: ${{ needs.compute-suffix.outputs.suffix }}
|
||||
version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
|
2
.github/workflows/build-releases.yml
vendored
2
.github/workflows/build-releases.yml
vendored
@ -20,6 +20,8 @@ jobs:
|
||||
use_native_arm64_builder: false
|
||||
push_to_images: |
|
||||
ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest = true
|
||||
secrets: inherit
|
||||
|
@ -101,7 +101,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452))
|
||||
- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378))
|
||||
- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874))
|
||||
- **Change local and federated timelines to be in a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
|
||||
- **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
|
||||
- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034))
|
||||
- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751))
|
||||
- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310))
|
||||
@ -189,6 +189,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073))
|
||||
- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218))
|
||||
- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
|
||||
- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808))
|
||||
- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
|
||||
- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
|
||||
- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375))
|
||||
|
@ -42,8 +42,8 @@ RUN apt-get update && \
|
||||
FROM node:${NODE_VERSION}
|
||||
|
||||
# Use those args to specify your own version flags & suffixes
|
||||
ARG MASTODON_VERSION_FLAGS=""
|
||||
ARG MASTODON_VERSION_SUFFIX=""
|
||||
ARG MASTODON_VERSION_PRERELEASE=""
|
||||
ARG MASTODON_VERSION_METADATA=""
|
||||
|
||||
ARG UID="991"
|
||||
ARG GID="991"
|
||||
@ -89,8 +89,8 @@ ENV RAILS_ENV="production" \
|
||||
NODE_ENV="production" \
|
||||
RAILS_SERVE_STATIC_FILES="true" \
|
||||
BIND="0.0.0.0" \
|
||||
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \
|
||||
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}"
|
||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"
|
||||
|
||||
# Set the run user
|
||||
USER mastodon
|
||||
|
24
Gemfile.lock
24
Gemfile.lock
@ -109,7 +109,7 @@ GEM
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.4)
|
||||
addressable (2.8.5)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
airbrussh (1.4.1)
|
||||
@ -124,8 +124,8 @@ GEM
|
||||
attr_required (1.0.1)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.793.0)
|
||||
aws-sdk-core (3.180.3)
|
||||
aws-partitions (1.809.0)
|
||||
aws-sdk-core (3.181.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@ -133,8 +133,8 @@ GEM
|
||||
aws-sdk-kms (1.71.0)
|
||||
aws-sdk-core (~> 3, >= 3.177.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.132.1)
|
||||
aws-sdk-core (~> 3, >= 3.179.0)
|
||||
aws-sdk-s3 (1.133.0)
|
||||
aws-sdk-core (~> 3, >= 3.181.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.6)
|
||||
aws-sigv4 (1.6.0)
|
||||
@ -203,7 +203,7 @@ GEM
|
||||
activesupport
|
||||
cbor (0.5.9.6)
|
||||
charlock_holmes (0.7.7)
|
||||
chewy (7.3.3)
|
||||
chewy (7.3.4)
|
||||
activesupport (>= 5.2)
|
||||
elasticsearch (>= 7.12.0, < 7.14.0)
|
||||
elasticsearch-dsl
|
||||
@ -324,7 +324,7 @@ GEM
|
||||
ruby-progressbar (~> 1.4)
|
||||
globalid (1.1.0)
|
||||
activesupport (>= 5.0)
|
||||
haml (6.1.1)
|
||||
haml (6.1.2)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
@ -333,7 +333,7 @@ GEM
|
||||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.49.3)
|
||||
haml_lint (0.50.0)
|
||||
haml (>= 4.0, < 6.2)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
@ -482,7 +482,7 @@ GEM
|
||||
nokogiri (1.15.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.0)
|
||||
oj (3.16.1)
|
||||
omniauth (2.1.1)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
@ -519,7 +519,7 @@ GEM
|
||||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.5.3)
|
||||
pg (1.5.4)
|
||||
pghero (3.3.3)
|
||||
activerecord (>= 6)
|
||||
posix-spawn (0.3.15)
|
||||
@ -731,7 +731,7 @@ GEM
|
||||
net-ssh (>= 2.8.0)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stoplight (3.0.1)
|
||||
stoplight (3.0.2)
|
||||
redlock (~> 1.0)
|
||||
strong_migrations (0.8.0)
|
||||
activerecord (>= 5.2)
|
||||
@ -795,7 +795,7 @@ GEM
|
||||
webfinger (1.2.0)
|
||||
activesupport
|
||||
httpclient (>= 2.4)
|
||||
webmock (3.18.1)
|
||||
webmock (3.19.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
|
@ -1,4 +1,4 @@
|
||||
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
|
||||
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
|
||||
stream: env PORT=4000 yarn run start
|
||||
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0
|
||||
webpack: bin/webpack-dev-server
|
||||
|
@ -21,12 +21,13 @@ class AccountsIndex < Chewy::Index
|
||||
|
||||
analyzer: {
|
||||
natural: {
|
||||
tokenizer: 'uax_url_email',
|
||||
tokenizer: 'standard',
|
||||
filter: %w(
|
||||
english_possessive_stemmer
|
||||
lowercase
|
||||
asciifolding
|
||||
cjk_width
|
||||
elision
|
||||
english_possessive_stemmer
|
||||
english_stop
|
||||
english_stemmer
|
||||
),
|
||||
@ -62,6 +63,6 @@ class AccountsIndex < Chewy::Index
|
||||
field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at })
|
||||
field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
|
||||
field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
|
||||
field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
|
||||
field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
|
||||
end
|
||||
end
|
||||
|
56
app/chewy/public_statuses_index.rb
Normal file
56
app/chewy/public_statuses_index.rb
Normal file
@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PublicStatusesIndex < Chewy::Index
|
||||
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
|
||||
filter: {
|
||||
english_stop: {
|
||||
type: 'stop',
|
||||
stopwords: '_english_',
|
||||
},
|
||||
|
||||
english_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'english',
|
||||
},
|
||||
|
||||
english_possessive_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'possessive_english',
|
||||
},
|
||||
},
|
||||
|
||||
analyzer: {
|
||||
verbatim: {
|
||||
tokenizer: 'uax_url_email',
|
||||
filter: %w(lowercase),
|
||||
},
|
||||
|
||||
content: {
|
||||
tokenizer: 'standard',
|
||||
filter: %w(
|
||||
lowercase
|
||||
asciifolding
|
||||
cjk_width
|
||||
elision
|
||||
english_possessive_stemmer
|
||||
english_stop
|
||||
english_stemmer
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
index_scope ::Status.unscoped
|
||||
.kept
|
||||
.indexable
|
||||
.includes(:media_attachments, :preloadable_poll, :preview_cards)
|
||||
|
||||
root date_detection: false do
|
||||
field(:id, type: 'long')
|
||||
field(:account_id, type: 'long')
|
||||
field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
|
||||
field(:language, type: 'keyword')
|
||||
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
|
||||
field(:created_at, type: 'date')
|
||||
end
|
||||
end
|
@ -1,18 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StatusesIndex < Chewy::Index
|
||||
include FormattingHelper
|
||||
|
||||
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
|
||||
filter: {
|
||||
english_stop: {
|
||||
type: 'stop',
|
||||
stopwords: '_english_',
|
||||
},
|
||||
|
||||
english_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'english',
|
||||
},
|
||||
|
||||
english_possessive_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'possessive_english',
|
||||
@ -28,10 +28,11 @@ class StatusesIndex < Chewy::Index
|
||||
content: {
|
||||
tokenizer: 'nori_user_dict',
|
||||
filter: %w(
|
||||
english_possessive_stemmer
|
||||
lowercase
|
||||
asciifolding
|
||||
cjk_width
|
||||
elision
|
||||
english_possessive_stemmer
|
||||
english_stop
|
||||
english_stemmer
|
||||
),
|
||||
@ -39,43 +40,15 @@ class StatusesIndex < Chewy::Index
|
||||
},
|
||||
}
|
||||
|
||||
# We do not use delete_if option here because it would call a method that we
|
||||
# expect to be called with crutches without crutches, causing n+1 queries
|
||||
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
|
||||
|
||||
crutch :mentions do |collection|
|
||||
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
crutch :favourites do |collection|
|
||||
data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
crutch :reblogs do |collection|
|
||||
data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
crutch :bookmarks do |collection|
|
||||
data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
crutch :votes do |collection|
|
||||
data = ::PollVote.joins(:poll).where(poll: { status_id: collection.map(&:id) }).where(account: Account.local).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preview_cards, :local_mentioned, :local_favorited, :local_reblogged, :local_bookmarked, preloadable_poll: :local_voters), delete_if: ->(status) { status.searchable_by.empty? }
|
||||
|
||||
root date_detection: false do
|
||||
field :id, type: 'long'
|
||||
field :account_id, type: 'long'
|
||||
|
||||
field :text, type: 'text', value: ->(status) { status.searchable_text } do
|
||||
field :stemmed, type: 'text', analyzer: 'content'
|
||||
end
|
||||
|
||||
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
|
||||
field(:id, type: 'long')
|
||||
field(:account_id, type: 'long')
|
||||
field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
|
||||
field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
|
||||
field(:language, type: 'keyword')
|
||||
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
|
||||
field(:created_at, type: 'date')
|
||||
end
|
||||
end
|
||||
|
18
app/controllers/admin/software_updates_controller.rb
Normal file
18
app/controllers/admin/software_updates_controller.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class SoftwareUpdatesController < BaseController
|
||||
before_action :check_enabled!
|
||||
|
||||
def index
|
||||
authorize :software_update, :index?
|
||||
@software_updates = SoftwareUpdate.all.sort_by(&:gem_version)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_enabled!
|
||||
not_found unless SoftwareUpdate.check_enabled?
|
||||
end
|
||||
end
|
||||
end
|
@ -30,6 +30,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
||||
:bot,
|
||||
:discoverable,
|
||||
:hide_collections,
|
||||
:indexable,
|
||||
fields_attributes: [:name, :value]
|
||||
)
|
||||
end
|
||||
|
@ -8,7 +8,15 @@ class Api::V1::Statuses::TranslationsController < Api::BaseController
|
||||
before_action :set_translation
|
||||
|
||||
rescue_from TranslationService::NotConfiguredError, with: :not_found
|
||||
rescue_from TranslationService::UnexpectedResponseError, TranslationService::QuotaExceededError, TranslationService::TooManyRequestsError, with: :service_unavailable
|
||||
rescue_from TranslationService::UnexpectedResponseError, with: :service_unavailable
|
||||
|
||||
rescue_from TranslationService::QuotaExceededError do
|
||||
render json: { error: I18n.t('translation.errors.quota_exceeded') }, status: 503
|
||||
end
|
||||
|
||||
rescue_from TranslationService::TooManyRequestsError do
|
||||
render json: { error: I18n.t('translation.errors.too_many_requests') }, status: 503
|
||||
end
|
||||
|
||||
def create
|
||||
render json: @translation, serializer: REST::TranslationSerializer
|
||||
|
@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::TagController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
|
||||
before_action :load_tag
|
||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||
|
||||
@ -12,6 +13,10 @@ class Api::V1::Timelines::TagController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def load_tag
|
||||
@tag = Tag.find_normalized(params[:id])
|
||||
end
|
||||
|
@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
|
||||
include DomainControlHelper
|
||||
include ThemingConcern
|
||||
include DatabaseHelper
|
||||
include AuthorizedFetchHelper
|
||||
|
||||
helper_method :current_account
|
||||
helper_method :current_session
|
||||
@ -53,10 +54,6 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def authorized_fetch_mode?
|
||||
ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.limited_federation_mode
|
||||
end
|
||||
|
||||
def public_fetch_mode?
|
||||
!authorized_fetch_mode?
|
||||
end
|
||||
|
@ -119,6 +119,8 @@ module SignatureVerification
|
||||
private
|
||||
|
||||
def fail_with!(message, **options)
|
||||
Rails.logger.warn { "Signature verification failed: #{message}" }
|
||||
|
||||
@signature_verification_failure_reason = { error: message }.merge(options)
|
||||
@signed_request_actor = nil
|
||||
end
|
||||
|
@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
|
||||
private
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:discoverable, :unlocked, :show_collections, settings: UserSettings.keys)
|
||||
params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys)
|
||||
end
|
||||
|
||||
def set_account
|
||||
|
11
app/helpers/authorized_fetch_helper.rb
Normal file
11
app/helpers/authorized_fetch_helper.rb
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AuthorizedFetchHelper
|
||||
def authorized_fetch_mode?
|
||||
ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch } == 'true' || Rails.configuration.x.limited_federation_mode
|
||||
end
|
||||
|
||||
def authorized_fetch_overridden?
|
||||
ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.limited_federation_mode
|
||||
end
|
||||
end
|
@ -1,28 +0,0 @@
|
||||
// This file will be loaded on public pages, regardless of theme.
|
||||
|
||||
import 'packs/public-path';
|
||||
|
||||
import { delegate } from '@rails/ujs';
|
||||
|
||||
const getProfileAvatarAnimationHandler = (swapTo) => {
|
||||
//animate avatar gifs on the profile page when moused over
|
||||
return ({ target }) => {
|
||||
const swapSrc = target.getAttribute(swapTo);
|
||||
//only change the img source if autoplay is off and the image src is actually different
|
||||
if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
|
||||
target.src = swapSrc;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
|
||||
|
||||
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '#account_header', 'change', ({ target }) => {
|
||||
const header = document.querySelector('.card .card__img img');
|
||||
const [file] = target.files || [];
|
||||
const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
|
||||
|
||||
header.src = url;
|
||||
});
|
@ -2,21 +2,6 @@
|
||||
|
||||
import 'packs/public-path';
|
||||
import { delegate } from '@rails/ujs';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
|
||||
import emojify from '../mastodon/features/emoji/emoji';
|
||||
|
||||
delegate(document, '#account_display_name', 'input', ({ target }) => {
|
||||
const name = document.querySelector('.card .display-name strong');
|
||||
if (name) {
|
||||
if (target.value) {
|
||||
name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
|
||||
} else {
|
||||
name.textContent = name.textContent = target.dataset.default;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
|
||||
const avatar = document.getElementById(target.id + '-preview');
|
||||
@ -26,18 +11,6 @@ delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
|
||||
avatar.src = url;
|
||||
});
|
||||
|
||||
delegate(document, '#account_locked', 'change', ({ target }) => {
|
||||
const lock = document.querySelector('.card .display-name i');
|
||||
|
||||
if (lock) {
|
||||
if (target.checked) {
|
||||
delete lock.dataset.hidden;
|
||||
} else {
|
||||
lock.dataset.hidden = 'true';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.input-copy input', 'click', ({ target }) => {
|
||||
target.focus();
|
||||
target.select();
|
||||
|
@ -13,8 +13,8 @@ pack:
|
||||
mailer:
|
||||
filename: mailer.js
|
||||
stylesheet: true
|
||||
modal: public.js
|
||||
public: public.js
|
||||
modal:
|
||||
public:
|
||||
settings: settings.js
|
||||
sign_up:
|
||||
share:
|
||||
|
@ -1,11 +1,16 @@
|
||||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||
|
||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
||||
export const REBLOG_FAIL = 'REBLOG_FAIL';
|
||||
|
||||
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
|
||||
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
|
||||
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
|
||||
|
||||
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||
@ -26,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
|
||||
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
|
||||
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
|
||||
|
||||
export const PIN_REQUEST = 'PIN_REQUEST';
|
||||
export const PIN_SUCCESS = 'PIN_SUCCESS';
|
||||
export const PIN_FAIL = 'PIN_FAIL';
|
||||
@ -269,8 +278,10 @@ export function fetchReblogs(id) {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchReblogsSuccess(id, response.data));
|
||||
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReblogsFail(id, error));
|
||||
});
|
||||
@ -284,17 +295,62 @@ export function fetchReblogsRequest(id) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReblogsSuccess(id, accounts) {
|
||||
export function fetchReblogsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandReblogsRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandReblogsFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsRequest(id) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@ -304,8 +360,10 @@ export function fetchFavourites(id) {
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
@ -319,17 +377,62 @@ export function fetchFavouritesRequest(id) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
export function fetchFavouritesSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFavouritesRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandFavouritesFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
@ -797,6 +797,7 @@ class Status extends ImmutablePureComponent {
|
||||
tabIndex={0}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
|
||||
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
|
||||
>
|
||||
{!muted && prepend}
|
||||
|
||||
|
@ -1,11 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@ -52,6 +48,16 @@ class Search extends PureComponent {
|
||||
options: [],
|
||||
};
|
||||
|
||||
defaultOptions = [
|
||||
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
|
||||
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
|
||||
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
|
||||
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
|
||||
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
|
||||
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
|
||||
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
|
||||
];
|
||||
|
||||
setRef = c => {
|
||||
this.searchForm = c;
|
||||
};
|
||||
@ -100,7 +106,7 @@ class Search extends PureComponent {
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { selectedOption } = this.state;
|
||||
const options = this._getOptions();
|
||||
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
@ -131,10 +137,9 @@ class Search extends PureComponent {
|
||||
if (selectedOption === -1) {
|
||||
this._submit();
|
||||
} else if (options.length > 0) {
|
||||
options[selectedOption].action();
|
||||
options[selectedOption].action(e);
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && options.length > 0) {
|
||||
@ -161,6 +166,7 @@ class Search extends PureComponent {
|
||||
|
||||
router.history.push(`/tags/${query}`);
|
||||
onClickSearchResult(query, 'hashtag');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleAccountClick = () => {
|
||||
@ -171,6 +177,7 @@ class Search extends PureComponent {
|
||||
|
||||
router.history.push(`/@${query}`);
|
||||
onClickSearchResult(query, 'account');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleURLClick = () => {
|
||||
@ -178,6 +185,7 @@ class Search extends PureComponent {
|
||||
const { onOpenURL } = this.props;
|
||||
|
||||
onOpenURL(router.history);
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleStatusSearch = () => {
|
||||
@ -196,6 +204,8 @@ class Search extends PureComponent {
|
||||
} else if (search.get('type') === 'hashtag') {
|
||||
router.history.push(`/tags/${search.get('q')}`);
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleForgetRecentSearchClick = search => {
|
||||
@ -208,6 +218,18 @@ class Search extends PureComponent {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
_insertText (text) {
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (value === '') {
|
||||
onChange(text);
|
||||
} else if (value[value.length - 1] === ' ') {
|
||||
onChange(`${value}${text}`);
|
||||
} else {
|
||||
onChange(`${value} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
_submit (type) {
|
||||
const { onSubmit, openInRoute } = this.props;
|
||||
const { router } = this.context;
|
||||
@ -217,6 +239,8 @@ class Search extends PureComponent {
|
||||
if (openInRoute) {
|
||||
router.history.push('/search');
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
}
|
||||
|
||||
_getOptions () {
|
||||
@ -337,6 +361,20 @@ class Search extends PureComponent {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchEnabled && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{this.defaultOptions.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchFavourites } from 'flavours/glitch/actions/interactions';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { fetchFavourites, expandFavourites } from 'flavours/glitch/actions/interactions';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
@ -23,7 +25,9 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
|
||||
});
|
||||
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
@ -32,6 +36,8 @@ class Favourites extends ImmutablePureComponent {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -42,12 +48,6 @@ class Favourites extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
@ -60,8 +60,12 @@ class Favourites extends ImmutablePureComponent {
|
||||
this.props.dispatch(fetchFavourites(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFavourites(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, multiColumn } = this.props;
|
||||
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -87,6 +91,9 @@ class Favourites extends ImmutablePureComponent {
|
||||
/>
|
||||
<ScrollableList
|
||||
scrollKey='favourites'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
|
@ -0,0 +1,26 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CriticalUpdateBanner = () => (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.title'
|
||||
defaultMessage='Critical security update available!'
|
||||
/>
|
||||
</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
|
||||
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { ColumnSettings } from './components/column_settings';
|
||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { ExplorePrompt } from './components/explore_prompt';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -158,8 +159,9 @@ class HomeTimeline extends PureComponent {
|
||||
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.context.identity;
|
||||
const banners = [];
|
||||
|
||||
let announcementsButton, banner;
|
||||
let announcementsButton;
|
||||
|
||||
if (hasAnnouncements) {
|
||||
announcementsButton = (
|
||||
@ -174,8 +176,12 @@ class HomeTimeline extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (criticalUpdatesPending) {
|
||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||
}
|
||||
|
||||
if (tooSlow) {
|
||||
banner = <ExplorePrompt />;
|
||||
banners.push(<ExplorePrompt key='explore-prompt' />);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -197,7 +203,7 @@ class HomeTimeline extends PureComponent {
|
||||
|
||||
{signedIn ? (
|
||||
<StatusListContainer
|
||||
prepend={banner}
|
||||
prepend={banners}
|
||||
alwaysPrepend
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`home_timeline-${columnId}`}
|
||||
|
@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchReblogs } from 'flavours/glitch/actions/interactions';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { fetchReblogs, expandReblogs } from 'flavours/glitch/actions/interactions';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
@ -16,17 +18,15 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import AccountContainer from 'flavours/glitch/containers/account_container';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' },
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
|
||||
});
|
||||
|
||||
class Reblogs extends ImmutablePureComponent {
|
||||
@ -35,6 +35,8 @@ class Reblogs extends ImmutablePureComponent {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -45,12 +47,6 @@ class Reblogs extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
@ -63,8 +59,12 @@ class Reblogs extends ImmutablePureComponent {
|
||||
this.props.dispatch(fetchReblogs(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandReblogs(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, multiColumn } = this.props;
|
||||
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -91,6 +91,9 @@ class Reblogs extends ImmutablePureComponent {
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='reblogs'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
|
@ -29,6 +29,7 @@ const messages = defineMessages({
|
||||
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
|
||||
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
|
||||
app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
||||
});
|
||||
|
||||
@ -56,9 +57,13 @@ class NavigationPanel extends Component {
|
||||
<div className='navigation-panel'>
|
||||
{transientSingleColumn && (
|
||||
<div className='navigation-panel__logo'>
|
||||
<a href={`/deck${location.pathname}`} className='button button--block'>
|
||||
<div class='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
)}
|
||||
|
@ -78,6 +78,7 @@ const PageThree = ({ myAccount }) => (
|
||||
onSubmit={noop}
|
||||
onClear={noop}
|
||||
onShow={noop}
|
||||
recent={{}}
|
||||
/>
|
||||
|
||||
<div className='pseudo-drawer'>
|
||||
|
@ -101,6 +101,7 @@ export const hasMultiColumnPath = initialPath === '/'
|
||||
* @typedef InitialState
|
||||
* @property {Record<string, Account>} accounts
|
||||
* @property {InitialStateLanguage[]} languages
|
||||
* @property {boolean=} critical_updates_pending
|
||||
* @property {InitialStateMeta} meta
|
||||
* @property {object} local_settings
|
||||
* @property {number} max_toot_chars
|
||||
@ -164,6 +165,7 @@ export const usePendingItems = getMeta('use_pending_items');
|
||||
export const version = getMeta('version');
|
||||
export const visibleReactions = getMeta('visible_reactions');
|
||||
export const languages = initialState?.languages;
|
||||
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
|
||||
|
@ -1,10 +1,43 @@
|
||||
{
|
||||
"compose.attach": "Vedhæft...",
|
||||
"compose.attach.doodle": "Tegn noget",
|
||||
"compose.attach.upload": "Upload en fil",
|
||||
"compose_form.poll.multiple_choices": "Tillad flere valg",
|
||||
"confirmations.missing_media_description.message": "Mindst én vedhæftet medie mangler en beskrivelse. Overvej at tilføje en beskrivelse af alle vedhæftede medier af hensyn til personer med nedsat syn, før du publicerer dit indlæg.",
|
||||
"empty_column.follow_recommendations": "Det ser ud til, at der ikke kunne genereres forslag til dig. Du kan prøve med Søg for at lede efter personer, du måske kender, eller udforske hashtags.",
|
||||
"follow_recommendations.done": "Udført",
|
||||
"follow_recommendations.heading": "Følg personer du gerne vil se indlæg fra! Her er nogle forslag.",
|
||||
"follow_recommendations.lead": "Indlæg, fra personer du følger, vil fremgå kronologisk ordnet i dit hjemmefeed. Vær ikke bange for at begå fejl, da du altid og meget nemt kan ændre dit valg!",
|
||||
"home.column_settings.advanced": "Avanceret",
|
||||
"home.column_settings.show_direct": "Vis private omtaler",
|
||||
"navigation_bar.app_settings": "Appindstillinger",
|
||||
"navigation_bar.misc": "Diverse",
|
||||
"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",
|
||||
"settings.preferences": "Preferences"
|
||||
"settings.always_show_spoilers_field": "Vis altid feltet til indholdsadvarsel",
|
||||
"settings.auto_collapse_media": "Indlæg med medier",
|
||||
"settings.close": "Luk",
|
||||
"settings.collapsed_statuses": "Sammenfoldede indlæg",
|
||||
"settings.content_warnings": "Indholdsadvarsler",
|
||||
"settings.content_warnings.regexp": "Regulært udtryk",
|
||||
"settings.general": "Generelt",
|
||||
"settings.image_backgrounds_media_hint": "Hvis et indlæg har vedhæftede medier, brug den første som baggrund",
|
||||
"settings.media": "Medier",
|
||||
"settings.preferences": "Præferencer",
|
||||
"settings.rewrite_mentions": "Omskriv omtaler i viste indlæg",
|
||||
"settings.rewrite_mentions_acct": "Omskriv med brugernavn og domæne (når brugeren ikke er lokal)",
|
||||
"settings.rewrite_mentions_no": "Omskriv ikke omtaler",
|
||||
"settings.rewrite_mentions_username": "Omskriv med brugernavn",
|
||||
"settings.show_reply_counter": "Vis et estimat over antal svar",
|
||||
"settings.status_icons": "Statusikoner",
|
||||
"settings.status_icons_language": "Sprogindikator",
|
||||
"settings.status_icons_local_only": "Kun lokal-indikator",
|
||||
"settings.status_icons_media": "Medie- og afstemningsindikator",
|
||||
"settings.status_icons_reply": "Svarindikator",
|
||||
"settings.status_icons_visibility": "Statussynlighedsindikator",
|
||||
"settings.tag_misleading_links": "Marker vildledende links",
|
||||
"status.has_audio": "Har vedhæftede lydfiler",
|
||||
"status.has_pictures": "Har vedhæftede billeder",
|
||||
"status.has_preview_card": "Har en vedhæftet linkvisning",
|
||||
"status.has_video": "Har vedhæftede videoer"
|
||||
}
|
||||
|
@ -1,8 +1,12 @@
|
||||
{
|
||||
"about.fork_disclaimer": "Glitch-socはMastodonからフォークされたフリーなオープンソースソフトウェアです。",
|
||||
"account.add_account_note": "@{name}のメモを追加",
|
||||
"account.disclaimer_full": "このユーザー情報は不正確な可能性があります。",
|
||||
"account.follows": "フォロー",
|
||||
"account.joined": "{date} に登録",
|
||||
"account.mute_notifications": "@{name}さんからの通知を受け取らない",
|
||||
"account.suspended_disclaimer_full": "このユーザーはモデレータにより停止されました。",
|
||||
"account.unmute_notifications": "@{name}さんからの通知を受け取る",
|
||||
"account.view_full_profile": "正確な情報を見る",
|
||||
"account_note.cancel": "キャンセル",
|
||||
"account_note.edit": "編集",
|
||||
@ -16,20 +20,25 @@
|
||||
"advanced_options.threaded_mode.short": "スレッドモード",
|
||||
"advanced_options.threaded_mode.tooltip": "スレッドモードを有効にする",
|
||||
"boost_modal.missing_description": "このトゥートには少なくとも1つの画像に説明が付与されていません",
|
||||
"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": "もう1度尋ねない",
|
||||
"confirmations.deprecated_settings.confirm": "Mastodonの設定を使用",
|
||||
"confirmations.missing_media_description.confirm": "このまま投稿",
|
||||
"confirmations.missing_media_description.edit": "メディアを編集",
|
||||
"confirmations.missing_media_description.message": "少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。",
|
||||
@ -38,6 +47,7 @@
|
||||
"confirmations.unfilter.edit_filter": "フィルターを編集",
|
||||
"confirmations.unfilter.filters": "適用されたフィルター",
|
||||
"content-type.change": "コンテンツ形式を変更",
|
||||
"direct.group_by_conversations": "会話でグループ化",
|
||||
"empty_column.follow_recommendations": "おすすめを生成できませんでした。検索を使って知り合いを探したり、トレンドハッシュタグを見てみましょう。",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "紹介しているユーザー",
|
||||
"favourite_modal.combo": "次からは {combo} を押せば、これをスキップできます。",
|
||||
@ -48,18 +58,22 @@
|
||||
"home.column_settings.advanced": "高度",
|
||||
"home.column_settings.filter_regex": "正規表現でフィルター",
|
||||
"home.column_settings.show_direct": "DMを表示",
|
||||
"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": "すべて\n選択",
|
||||
"notification_purge.btn_apply": "選択したものを\n削除",
|
||||
"notification_purge.btn_invert": "選択を\n反転",
|
||||
"notification_purge.btn_none": "選択\n解除",
|
||||
"notification_purge.start": "通知整理モードに入る",
|
||||
"notifications.marked_clear": "選択した通知を削除する",
|
||||
"notifications.marked_clear_confirmation": "削除した全ての通知を完全に削除してもよろしいですか?",
|
||||
"onboarding.page_one.federation": "{domain}はMastodonのインスタンスです。Mastodonとは、独立したサーバが連携して作るソーシャルネットワークです。これらのサーバーをインスタンスと呼びます。",
|
||||
@ -68,6 +82,7 @@
|
||||
"settings.always_show_spoilers_field": "常にコンテンツワーニング設定を表示する(指定がない場合は通常投稿)",
|
||||
"settings.auto_collapse": "自動折りたたみ",
|
||||
"settings.auto_collapse_all": "すべて",
|
||||
"settings.auto_collapse_height": "トゥートが長いと見なされる高さ(ピクセル)",
|
||||
"settings.auto_collapse_lengthy": "長いトゥート",
|
||||
"settings.auto_collapse_media": "メディア付きトゥート",
|
||||
"settings.auto_collapse_notifications": "通知",
|
||||
@ -82,6 +97,9 @@
|
||||
"settings.content_warnings": "コンテンツワーニング",
|
||||
"settings.content_warnings.regexp": "正規表現",
|
||||
"settings.content_warnings_filter": "説明に指定した文字が含まれているものを自動で展開しないようにする",
|
||||
"settings.content_warnings_media_outside": "コンテンツワーニングの外側にメディア添付ファイルを表示する",
|
||||
"settings.content_warnings_shared_state": "すべてのコピーの内容を一度に表示/非表示",
|
||||
"settings.content_warnings_unfold_opts": "自動展開オプション",
|
||||
"settings.enable_collapsed": "トゥート折りたたみを有効にする",
|
||||
"settings.enable_content_warnings_auto_unfold": "コンテンツワーニング指定されている投稿を常に表示する",
|
||||
"settings.general": "一般",
|
||||
@ -119,10 +137,24 @@
|
||||
"settings.side_arm_reply_mode.copy": "返信先の投稿範囲を利用する",
|
||||
"settings.side_arm_reply_mode.keep": "セカンダリートゥートボタンの設定を維持する",
|
||||
"settings.side_arm_reply_mode.restrict": "返信先の投稿範囲に制限する",
|
||||
"settings.status_icons": "トゥートアイコン",
|
||||
"settings.status_icons_language": "言語インジケータ",
|
||||
"settings.status_icons_local_only": "ローカル限定インジケータ",
|
||||
"settings.status_icons_media": "メディア・アンケートインジケータ",
|
||||
"settings.status_icons_reply": "返信インジケータ",
|
||||
"settings.status_icons_visibility": "公開範囲インジケータ",
|
||||
"settings.swipe_to_change_columns": "スワイプでカラムを切り替え可能にする(モバイルのみ)",
|
||||
"settings.tag_misleading_links": "誤解を招くリンクにタグをつける",
|
||||
"settings.tag_misleading_links.hint": "明示的に言及していないすべてのリンクに、リンクターゲットホストを含む視覚的な表示を追加します",
|
||||
"settings.wide_view": "ワイドビュー(デスクトップ レイアウトのみ)",
|
||||
"status.collapse": "折りたたむ",
|
||||
"status.has_audio": "添付されたオーディオファイルが表示されます",
|
||||
"status.has_pictures": "添付された画像が表示されます",
|
||||
"status.has_preview_card": "添付されたプレビューカードが表示されます",
|
||||
"status.has_video": "添付動画が表示されます",
|
||||
"status.in_reply_to": "このトゥートは返信です",
|
||||
"status.is_poll": "このトゥートはアンケートです",
|
||||
"status.local_only": "あなたのインスタンスのみに公開",
|
||||
"status.sensitive_toggle": "クリックして表示",
|
||||
"status.uncollapse": "折りたたみを解除"
|
||||
}
|
||||
|
@ -16,11 +16,17 @@
|
||||
"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": "塗鴉",
|
||||
@ -30,27 +36,66 @@
|
||||
"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": "使用 Mastodon 偏好",
|
||||
"confirmations.deprecated_settings.message": "您正在使用的某些特定於 glitch-soc 設備的 {app_settings} 已被 Mastodon {preferences} 所取代,並將被覆蓋:",
|
||||
"confirmations.missing_media_description.confirm": "仍要張貼",
|
||||
"confirmations.missing_media_description.edit": "編輯媒體",
|
||||
"confirmations.missing_media_description.message": "至少有一個媒體附件缺少說明。 在發送嘟文之前,請考慮為視障人士在所有媒體附件加上說明。",
|
||||
"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} 跳過",
|
||||
"firehose.column_settings.allow_local_only": "在「全部」顯示僅限本地的貼文",
|
||||
"follow_recommendations.done": "完成",
|
||||
"follow_recommendations.heading": "跟隨您想檢視其嘟文的人!這裡有一些建議。",
|
||||
"follow_recommendations.lead": "來自您跟隨的人之嘟文將會按時間順序顯示在您的首頁時間軸上。不要害怕犯錯,您隨時都可以取消跟隨其他人!",
|
||||
"getting_started.onboarding": "帶我四處看看",
|
||||
"home.column_settings.advanced": "進階設定",
|
||||
"home.column_settings.filter_regex": "以正規表達式進行過濾",
|
||||
"home.column_settings.show_direct": "顯示私人提及",
|
||||
"home.settings": "欄位設定",
|
||||
"keyboard_shortcuts.bookmark": "到書籤",
|
||||
"keyboard_shortcuts.secondary_toot": "使用次要隱私設定來發布嘟文",
|
||||
"keyboard_shortcuts.toggle_collapse": "去折疊/展開嘟文",
|
||||
"media_gallery.sensitive": "敏感",
|
||||
"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_four.home": "首頁時間線會顯示你追隨的人發布的貼文。",
|
||||
"onboarding.page_four.notifications": "當有人與您互動時會顯示在通知欄。",
|
||||
"onboarding.page_one.federation": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
|
||||
"onboarding.page_one.handle": "你的帳號在 {domain} ,所以你的帳號全名是 {handle}",
|
||||
"onboarding.page_one.welcome": "歡迎來到 {domain} !",
|
||||
"onboarding.page_six.admin": "您的站台管理者是 {admin} 。",
|
||||
"onboarding.page_six.almost_done": "就快完成了…",
|
||||
"onboarding.page_six.apps_available": "有適用於 iOS、Android 和其他平台的 {apps}。",
|
||||
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
|
||||
"onboarding.page_six.guidelines": "社群規範",
|
||||
"onboarding.page_six.read_guidelines": "請閱讀 {domain} 的 {guidelines}!",
|
||||
"onboarding.page_six.various_app": "手機應用程式",
|
||||
"onboarding.page_three.profile": "編輯您的個人資料以更改您的頭像、個人簡介和顯示名稱。在那裡,您還會發現其他偏好設置。",
|
||||
"onboarding.page_three.search": "使用搜索欄查找他人與主題標籤,例如 {illustration} 和 {introductions} 。要尋找其他站台的人,請使用他們的完整帳號名稱。",
|
||||
"onboarding.page_two.compose": "從撰寫欄撰寫帖子。您可以使用下面的圖示上傳圖片、更改隱私設置以及添加內容警告。",
|
||||
"onboarding.skip": "略過",
|
||||
"settings.always_show_spoilers_field": "永遠啟用內容警告欄位",
|
||||
"settings.auto_collapse": "自動折疊",
|
||||
"settings.auto_collapse_all": "全部",
|
||||
@ -83,19 +128,23 @@
|
||||
"settings.hicolor_privacy_icons.hint": "用明亮且易於區分的顏色顯示隱私圖示",
|
||||
"settings.image_backgrounds": "圖片背景",
|
||||
"settings.image_backgrounds_media": "預覽折疊嘟文的媒體檔案",
|
||||
"settings.image_backgrounds_media_hint": "如果嘟文包含媒體檔案,使用的一個作為圖片背景",
|
||||
"settings.image_backgrounds_media_hint": "如果嘟文包含媒體檔案,使用第一個作為圖片背景",
|
||||
"settings.image_backgrounds_users": "為折疊的嘟文加上圖片背景",
|
||||
"settings.inline_preview_cards": "針對外部連接顯示內嵌的預覽卡",
|
||||
"settings.layout_opts": "版面選項",
|
||||
"settings.media": "媒體",
|
||||
"settings.media_fullwidth": "在媒體預覽中使用完整寬度",
|
||||
"settings.media_letterbox": "在媒體預覽加上黑邊",
|
||||
"settings.media_letterbox_hint": "在媒體預覽中縮小並加上黑邊以取代延展與裁切",
|
||||
"settings.media_reveal_behind_cw": "預設顯示隱藏在內容警告的敏感媒體檔案",
|
||||
"settings.notifications.favicon_badge": "未讀通知網站圖示徽章",
|
||||
"settings.notifications.favicon_badge.hint": "在網站圖示上增加一個未讀通知徽章",
|
||||
"settings.notifications.tab_badge": "未讀通知徽章",
|
||||
"settings.notifications.tab_badge.hint": "當通知列未打開時,在導引圖示中顯示未讀通知的徽章",
|
||||
"settings.notifications_opts": "通知選項",
|
||||
"settings.pop_in_left": "左邊",
|
||||
"settings.pop_in_player": "啟用彈出播放器",
|
||||
"settings.pop_in_position": "彈出播放器位置:",
|
||||
"settings.pop_in_right": "右邊",
|
||||
"settings.preferences": "使用者偏好設定",
|
||||
"settings.prepend_cw_re": "回覆時在內容警告前添加 \"re:\"",
|
||||
@ -105,6 +154,7 @@
|
||||
"settings.rewrite_mentions_acct": "改寫為使用者名稱與網域(當使用者來自外部)",
|
||||
"settings.rewrite_mentions_no": "不要改寫提及",
|
||||
"settings.rewrite_mentions_username": "改寫為使用者名稱",
|
||||
"settings.shared_settings_link": "使用者偏好設定",
|
||||
"settings.show_action_bar": "在折疊的嘟文顯示操作按鈕",
|
||||
"settings.show_content_type_choice": "在編寫嘟文時顯示內容類型選擇",
|
||||
"settings.show_reply_counter": "顯示回覆數量的估計值",
|
||||
@ -113,12 +163,14 @@
|
||||
"settings.side_arm_reply_mode": "當回覆一篇嘟文時,次要發出嘟文按鈕應該設為:",
|
||||
"settings.side_arm_reply_mode.copy": "複製回覆嘟文的隱私設置",
|
||||
"settings.side_arm_reply_mode.keep": "保持原本的隱私設定",
|
||||
"settings.side_arm_reply_mode.restrict": "限制只能使用與回覆嘟文相同的隱私設置",
|
||||
"settings.status_icons": "嘟文圖示",
|
||||
"settings.status_icons_language": "語言指示器",
|
||||
"settings.status_icons_local_only": "僅限本地指示器",
|
||||
"settings.status_icons_media": "媒體與投票指示器",
|
||||
"settings.status_icons_reply": "回覆指示器",
|
||||
"settings.status_icons_visibility": "嘟文隱私指示器",
|
||||
"settings.swipe_to_change_columns": "允許使用滑動手勢更改顯示欄位(僅限移動裝置)",
|
||||
"settings.tag_misleading_links": "標記誤導性的連結",
|
||||
"settings.tag_misleading_links.hint": "在每個未明確提及的連結添加帶有連結目標主機的視覺指示",
|
||||
"settings.wide_view": "寬廣模式(僅限桌面模式)",
|
||||
@ -130,5 +182,17 @@
|
||||
"status.has_video": "包含視訊檔案",
|
||||
"status.in_reply_to": "嘟文有回覆",
|
||||
"status.is_poll": "嘟文有投票",
|
||||
"status.local_only": "只在此實例可見"
|
||||
"status.local_only": "只在此實例可見",
|
||||
"status.sensitive_toggle": "點擊查看",
|
||||
"status.uncollapse": "展開",
|
||||
"web_app_crash.change_your_settings": "修改你的 {settings}",
|
||||
"web_app_crash.content": "您可以嘗試以下任一種方法:",
|
||||
"web_app_crash.debug_info": "除錯資訊",
|
||||
"web_app_crash.disable_addons": "禁用瀏覽器插件或內置翻譯工具",
|
||||
"web_app_crash.issue_tracker": "問題追蹤系統",
|
||||
"web_app_crash.reload": "重新載入",
|
||||
"web_app_crash.reload_page": "{reload} 當前頁面",
|
||||
"web_app_crash.report_issue": "到 {issuetracker} 回報問題",
|
||||
"web_app_crash.settings": "設定",
|
||||
"web_app_crash.title": "很抱歉,Mastodon 應用程序出現問題。"
|
||||
}
|
||||
|
@ -44,8 +44,18 @@ import {
|
||||
FEATURED_TAGS_FETCH_FAIL,
|
||||
} from 'flavours/glitch/actions/featured_tags';
|
||||
import {
|
||||
REBLOGS_FETCH_REQUEST,
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
REBLOGS_FETCH_FAIL,
|
||||
REBLOGS_EXPAND_REQUEST,
|
||||
REBLOGS_EXPAND_SUCCESS,
|
||||
REBLOGS_EXPAND_FAIL,
|
||||
FAVOURITES_FETCH_REQUEST,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_FAIL,
|
||||
FAVOURITES_EXPAND_REQUEST,
|
||||
FAVOURITES_EXPAND_SUCCESS,
|
||||
FAVOURITES_EXPAND_FAIL,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import {
|
||||
MUTES_FETCH_REQUEST,
|
||||
@ -133,9 +143,25 @@ export default function userLists(state = initialState, action) {
|
||||
case FOLLOWING_EXPAND_FAIL:
|
||||
return state.setIn(['following', action.id, 'isLoading'], false);
|
||||
case REBLOGS_FETCH_SUCCESS:
|
||||
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||
return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
|
||||
case REBLOGS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
|
||||
case REBLOGS_FETCH_REQUEST:
|
||||
case REBLOGS_EXPAND_REQUEST:
|
||||
return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
|
||||
case REBLOGS_FETCH_FAIL:
|
||||
case REBLOGS_EXPAND_FAIL:
|
||||
return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
|
||||
case FAVOURITES_FETCH_SUCCESS:
|
||||
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||
return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
|
||||
case FAVOURITES_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
|
||||
case FAVOURITES_FETCH_REQUEST:
|
||||
case FAVOURITES_EXPAND_REQUEST:
|
||||
return state.setIn(['favourited_by', action.id, 'isLoading'], true);
|
||||
case FAVOURITES_FETCH_FAIL:
|
||||
case FAVOURITES_EXPAND_FAIL:
|
||||
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
|
||||
case NOTIFICATIONS_UPDATE:
|
||||
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
|
||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||
|
@ -192,6 +192,8 @@
|
||||
}
|
||||
|
||||
.account-role,
|
||||
.information-badge,
|
||||
.simple_form .overridden,
|
||||
.simple_form .recommended,
|
||||
.simple_form .not_recommended,
|
||||
.simple_form .glitch_only {
|
||||
|
@ -143,6 +143,11 @@ $content-width: 840px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning a {
|
||||
color: $gold-star;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.simple-navigation-active-leaf a {
|
||||
color: $primary-text-color;
|
||||
background-color: $ui-highlight-color;
|
||||
|
@ -228,6 +228,22 @@ $ui-header-height: 55px;
|
||||
top: -48px;
|
||||
}
|
||||
|
||||
.switch-to-advanced {
|
||||
color: $classic-primary-color;
|
||||
background-color: $classic-base-color;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
|
||||
.switch-to-advanced__toggle {
|
||||
color: $ui-button-tertiary-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.column-link {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
color: $primary-text-color;
|
||||
@ -961,7 +977,8 @@ $ui-header-height: 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.dismissable-banner {
|
||||
.dismissable-banner,
|
||||
.warning-banner {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
margin-bottom: 5px;
|
||||
@ -1039,6 +1056,21 @@ $ui-header-height: 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
border: 1px solid $warning-red;
|
||||
background: rgba($warning-red, 0.15);
|
||||
|
||||
&__message {
|
||||
h1 {
|
||||
color: $warning-red;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-header {
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
padding: 15px;
|
||||
|
@ -25,6 +25,12 @@
|
||||
}
|
||||
|
||||
&__menu {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: $dark-text-color;
|
||||
padding: 0 10px;
|
||||
|
@ -120,6 +120,7 @@
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__textarea {
|
||||
|
@ -103,6 +103,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.overridden,
|
||||
.recommended,
|
||||
.not_recommended,
|
||||
.glitch_only {
|
||||
@ -1187,14 +1188,14 @@ code {
|
||||
}
|
||||
|
||||
li:first-child .label {
|
||||
left: auto;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
text-align: start;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
li:last-child .label {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
text-align: end;
|
||||
transform: none;
|
||||
|
@ -12,6 +12,11 @@
|
||||
border-top: 1px solid $ui-base-color;
|
||||
text-align: start;
|
||||
background: darken($ui-base-color, 4%);
|
||||
|
||||
&.critical {
|
||||
font-weight: 700;
|
||||
color: $gold-star;
|
||||
}
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
|
@ -84,6 +84,7 @@ const messages = defineMessages({
|
||||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
});
|
||||
|
||||
export const ensureComposeIsVisible = (getState, routerHistory) => {
|
||||
@ -246,7 +247,7 @@ export function submitCompose(routerHistory) {
|
||||
}
|
||||
|
||||
dispatch(showAlert({
|
||||
message: messages.published,
|
||||
message: statusId === null ? messages.published : messages.saved,
|
||||
action: messages.open,
|
||||
dismissAfter: 10000,
|
||||
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
|
||||
|
@ -1,11 +1,16 @@
|
||||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||
|
||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
||||
export const REBLOG_FAIL = 'REBLOG_FAIL';
|
||||
|
||||
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
|
||||
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
|
||||
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
|
||||
|
||||
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||
@ -26,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
|
||||
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
|
||||
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
|
||||
|
||||
export const PIN_REQUEST = 'PIN_REQUEST';
|
||||
export const PIN_SUCCESS = 'PIN_SUCCESS';
|
||||
export const PIN_FAIL = 'PIN_FAIL';
|
||||
@ -283,8 +292,10 @@ export function fetchReblogs(id) {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchReblogsSuccess(id, response.data));
|
||||
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReblogsFail(id, error));
|
||||
});
|
||||
@ -298,17 +309,62 @@ export function fetchReblogsRequest(id) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReblogsSuccess(id, accounts) {
|
||||
export function fetchReblogsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandReblogsRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandReblogsFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsRequest(id) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@ -318,8 +374,10 @@ export function fetchFavourites(id) {
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
@ -333,17 +391,62 @@ export function fetchFavouritesRequest(id) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
export function fetchFavouritesSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFavouritesRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandFavouritesFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
@ -10,8 +10,8 @@ import { groupBy, minBy } from 'lodash';
|
||||
|
||||
import { getStatusContent } from './status_content';
|
||||
|
||||
// About two lines on desktop
|
||||
const VISIBLE_HASHTAGS = 7;
|
||||
// Fit on a single line on desktop
|
||||
const VISIBLE_HASHTAGS = 3;
|
||||
|
||||
// Those types are not correct, they need to be replaced once this part of the state is typed
|
||||
export type TagLike = Record<{ name: string }>;
|
||||
@ -210,7 +210,7 @@ const HashtagBar: React.FC<{
|
||||
|
||||
const revealedHashtags = expanded
|
||||
? hashtags
|
||||
: hashtags.slice(0, VISIBLE_HASHTAGS - 1);
|
||||
: hashtags.slice(0, VISIBLE_HASHTAGS);
|
||||
|
||||
return (
|
||||
<div className='hashtag-bar'>
|
||||
|
@ -555,7 +555,7 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@ -45,6 +45,16 @@ class Search extends PureComponent {
|
||||
options: [],
|
||||
};
|
||||
|
||||
defaultOptions = [
|
||||
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
|
||||
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
|
||||
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
|
||||
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
|
||||
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
|
||||
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
|
||||
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
|
||||
];
|
||||
|
||||
setRef = c => {
|
||||
this.searchForm = c;
|
||||
};
|
||||
@ -70,7 +80,7 @@ class Search extends PureComponent {
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { selectedOption } = this.state;
|
||||
const options = this._getOptions();
|
||||
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
@ -100,11 +110,9 @@ class Search extends PureComponent {
|
||||
if (selectedOption === -1) {
|
||||
this._submit();
|
||||
} else if (options.length > 0) {
|
||||
options[selectedOption].action();
|
||||
options[selectedOption].action(e);
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && options.length > 0) {
|
||||
@ -147,6 +155,7 @@ class Search extends PureComponent {
|
||||
|
||||
router.history.push(`/tags/${query}`);
|
||||
onClickSearchResult(query, 'hashtag');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleAccountClick = () => {
|
||||
@ -157,6 +166,7 @@ class Search extends PureComponent {
|
||||
|
||||
router.history.push(`/@${query}`);
|
||||
onClickSearchResult(query, 'account');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleURLClick = () => {
|
||||
@ -164,6 +174,7 @@ class Search extends PureComponent {
|
||||
const { value, onOpenURL } = this.props;
|
||||
|
||||
onOpenURL(value, router.history);
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleStatusSearch = () => {
|
||||
@ -182,6 +193,8 @@ class Search extends PureComponent {
|
||||
} else if (search.get('type') === 'hashtag') {
|
||||
router.history.push(`/tags/${search.get('q')}`);
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleForgetRecentSearchClick = search => {
|
||||
@ -194,6 +207,18 @@ class Search extends PureComponent {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
_insertText (text) {
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (value === '') {
|
||||
onChange(text);
|
||||
} else if (value[value.length - 1] === ' ') {
|
||||
onChange(`${value}${text}`);
|
||||
} else {
|
||||
onChange(`${value} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
_submit (type) {
|
||||
const { onSubmit, openInRoute } = this.props;
|
||||
const { router } = this.context;
|
||||
@ -203,6 +228,8 @@ class Search extends PureComponent {
|
||||
if (openInRoute) {
|
||||
router.history.push('/search');
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
}
|
||||
|
||||
_getOptions () {
|
||||
@ -325,6 +352,20 @@ class Search extends PureComponent {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchEnabled && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{this.defaultOptions.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchFavourites } from 'mastodon/actions/interactions';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
@ -21,7 +23,9 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
|
||||
});
|
||||
|
||||
class Favourites extends ImmutablePureComponent {
|
||||
@ -30,6 +34,8 @@ class Favourites extends ImmutablePureComponent {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -40,18 +46,16 @@ class Favourites extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchFavourites(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFavourites(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, multiColumn } = this.props;
|
||||
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -75,6 +79,9 @@ class Favourites extends ImmutablePureComponent {
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='favourites'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
|
@ -0,0 +1,26 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CriticalUpdateBanner = () => (
|
||||
<div className='warning-banner'>
|
||||
<div className='warning-banner__message'>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.title'
|
||||
defaultMessage='Critical security update available!'
|
||||
/>
|
||||
</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.body'
|
||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||
/>{' '}
|
||||
<a href='/admin/software_updates'>
|
||||
<FormattedMessage
|
||||
id='home.pending_critical_update.link'
|
||||
defaultMessage='See updates'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { me, criticalUpdatesPending } from 'mastodon/initial_state';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { ColumnSettings } from './components/column_settings';
|
||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { ExplorePrompt } from './components/explore_prompt';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent {
|
||||
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.context.identity;
|
||||
const banners = [];
|
||||
|
||||
let announcementsButton, banner;
|
||||
let announcementsButton;
|
||||
|
||||
if (hasAnnouncements) {
|
||||
announcementsButton = (
|
||||
@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (criticalUpdatesPending) {
|
||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||
}
|
||||
|
||||
if (tooSlow) {
|
||||
banner = <ExplorePrompt />;
|
||||
banners.push(<ExplorePrompt key='explore-prompt' />);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent {
|
||||
|
||||
{signedIn ? (
|
||||
<StatusListContainer
|
||||
prepend={banner}
|
||||
prepend={banners}
|
||||
alwaysPrepend
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`home_timeline-${columnId}`}
|
||||
|
@ -8,9 +8,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import { fetchReblogs } from '../../actions/interactions';
|
||||
import { fetchReblogs, expandReblogs } from '../../actions/interactions';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
@ -22,7 +24,9 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
|
||||
});
|
||||
|
||||
class Reblogs extends ImmutablePureComponent {
|
||||
@ -31,6 +35,8 @@ class Reblogs extends ImmutablePureComponent {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -39,20 +45,18 @@ class Reblogs extends ImmutablePureComponent {
|
||||
if (!this.props.accountIds) {
|
||||
this.props.dispatch(fetchReblogs(this.props.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchReblogs(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandReblogs(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, multiColumn } = this.props;
|
||||
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
@ -76,6 +80,9 @@ class Reblogs extends ImmutablePureComponent {
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='reblogs'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
|
@ -31,6 +31,7 @@ const messages = defineMessages({
|
||||
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
|
||||
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
|
||||
});
|
||||
|
||||
class NavigationPanel extends Component {
|
||||
@ -57,12 +58,17 @@ class NavigationPanel extends Component {
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
|
||||
|
||||
{transientSingleColumn && (
|
||||
<a href={`/deck${location.pathname}`} className='button button--block'>
|
||||
{transientSingleColumn ? (
|
||||
<div class='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<hr />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signedIn && (
|
||||
|
@ -89,6 +89,7 @@
|
||||
* @typedef InitialState
|
||||
* @property {Record<string, Account>} accounts
|
||||
* @property {InitialStateLanguage[]} languages
|
||||
* @property {boolean=} critical_updates_pending
|
||||
* @property {InitialStateMeta} meta
|
||||
* @property {number} max_toot_chars
|
||||
* @property {number} max_reactions
|
||||
@ -146,6 +147,7 @@ export const usePendingItems = getMeta('use_pending_items');
|
||||
export const version = getMeta('version');
|
||||
export const visibleReactions = getMeta('visible_reactions');
|
||||
export const languages = initialState?.languages;
|
||||
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||
// @ts-expect-error
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
|
@ -137,6 +137,7 @@
|
||||
"compose.language.search": "Search languages...",
|
||||
"compose.published.body": "Post published.",
|
||||
"compose.published.open": "Open",
|
||||
"compose.saved.body": "Post saved.",
|
||||
"compose_form.direct_message_warning_learn_more": "Learn more",
|
||||
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
|
||||
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
|
||||
@ -309,6 +310,9 @@
|
||||
"home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
|
||||
"home.explore_prompt.title": "This is your home base within Mastodon.",
|
||||
"home.hide_announcements": "Hide announcements",
|
||||
"home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
|
||||
"home.pending_critical_update.link": "See updates",
|
||||
"home.pending_critical_update.title": "Critical security update available!",
|
||||
"home.show_announcements": "Show announcements",
|
||||
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
|
||||
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
|
||||
@ -410,6 +414,7 @@
|
||||
"navigation_bar.lists": "Lists",
|
||||
"navigation_bar.logout": "Logout",
|
||||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Pinned posts",
|
||||
"navigation_bar.preferences": "Preferences",
|
||||
@ -587,8 +592,12 @@
|
||||
"search.quick_action.open_url": "Open URL in Mastodon",
|
||||
"search.quick_action.status_search": "Posts matching {x}",
|
||||
"search.search_or_paste": "Search or paste URL",
|
||||
"search_popout.language_code": "ISO language code",
|
||||
"search_popout.options": "Search options",
|
||||
"search_popout.quick_actions": "Quick actions",
|
||||
"search_popout.recent": "Recent searches",
|
||||
"search_popout.specific_date": "specific date",
|
||||
"search_popout.user": "user",
|
||||
"search_results.accounts": "Profiles",
|
||||
"search_results.all": "All",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
|
@ -409,6 +409,7 @@
|
||||
"navigation_bar.lists": "Listes",
|
||||
"navigation_bar.logout": "Déconnexion",
|
||||
"navigation_bar.mutes": "Comptes masqués",
|
||||
"navigation_bar.opened_in_classic_interface": "Les messages, les comptes et les pages spécifiques sont ouvertes dans l’interface classique.",
|
||||
"navigation_bar.personal": "Personnel",
|
||||
"navigation_bar.pins": "Messages épinglés",
|
||||
"navigation_bar.preferences": "Préférences",
|
||||
|
@ -45,8 +45,18 @@ import {
|
||||
BLOCKS_EXPAND_FAIL,
|
||||
} from '../actions/blocks';
|
||||
import {
|
||||
REBLOGS_FETCH_REQUEST,
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
REBLOGS_FETCH_FAIL,
|
||||
REBLOGS_EXPAND_REQUEST,
|
||||
REBLOGS_EXPAND_SUCCESS,
|
||||
REBLOGS_EXPAND_FAIL,
|
||||
FAVOURITES_FETCH_REQUEST,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_FAIL,
|
||||
FAVOURITES_EXPAND_REQUEST,
|
||||
FAVOURITES_EXPAND_SUCCESS,
|
||||
FAVOURITES_EXPAND_FAIL,
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
MUTES_FETCH_REQUEST,
|
||||
@ -134,9 +144,25 @@ export default function userLists(state = initialState, action) {
|
||||
case FOLLOWING_EXPAND_FAIL:
|
||||
return state.setIn(['following', action.id, 'isLoading'], false);
|
||||
case REBLOGS_FETCH_SUCCESS:
|
||||
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||
return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
|
||||
case REBLOGS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
|
||||
case REBLOGS_FETCH_REQUEST:
|
||||
case REBLOGS_EXPAND_REQUEST:
|
||||
return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
|
||||
case REBLOGS_FETCH_FAIL:
|
||||
case REBLOGS_EXPAND_FAIL:
|
||||
return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
|
||||
case FAVOURITES_FETCH_SUCCESS:
|
||||
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||
return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
|
||||
case FAVOURITES_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
|
||||
case FAVOURITES_FETCH_REQUEST:
|
||||
case FAVOURITES_EXPAND_REQUEST:
|
||||
return state.setIn(['favourited_by', action.id, 'isLoading'], true);
|
||||
case FAVOURITES_FETCH_FAIL:
|
||||
case FAVOURITES_EXPAND_FAIL:
|
||||
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
|
||||
case NOTIFICATIONS_UPDATE:
|
||||
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
|
||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||
|
@ -1 +1 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
@ -7,7 +7,6 @@ import { defineMessages } from 'react-intl';
|
||||
|
||||
import { delegate } from '@rails/ujs';
|
||||
import axios from 'axios';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
@ -31,23 +30,6 @@ const messages = defineMessages({
|
||||
function loaded() {
|
||||
const { messages: localeData } = getLocale();
|
||||
|
||||
const scrollToDetailedStatus = () => {
|
||||
const history = createBrowserHistory();
|
||||
const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
|
||||
const location = history.location;
|
||||
|
||||
if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
|
||||
detailedStatuses[0].scrollIntoView();
|
||||
history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
|
||||
}
|
||||
};
|
||||
|
||||
const getEmojiAnimationHandler = (swapTo) => {
|
||||
return ({ target }) => {
|
||||
target.src = target.getAttribute(swapTo);
|
||||
};
|
||||
};
|
||||
|
||||
const locale = document.documentElement.lang;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
@ -141,27 +123,21 @@ function loaded() {
|
||||
const root = createRoot(content);
|
||||
root.render(<MediaContainer locale={locale} components={reactComponents} />);
|
||||
document.body.appendChild(content);
|
||||
scrollToDetailedStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
scrollToDetailedStatus();
|
||||
});
|
||||
} else {
|
||||
scrollToDetailedStatus();
|
||||
}
|
||||
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
|
||||
const username = document.getElementById('user_account_attributes_username');
|
||||
|
||||
if (username.value && username.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
|
||||
username.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(({ target }) => {
|
||||
if (target.value && target.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: target.value } }).then(() => {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}).catch(() => {
|
||||
username.setCustomValidity('');
|
||||
target.setCustomValidity('');
|
||||
});
|
||||
} else {
|
||||
username.setCustomValidity('');
|
||||
target.setCustomValidity('');
|
||||
}
|
||||
}, 500, { leading: false, trailing: true }));
|
||||
|
||||
@ -179,9 +155,6 @@ function loaded() {
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '.status__content__spoiler-link', 'click', function() {
|
||||
const statusEl = this.parentNode.parentNode;
|
||||
|
||||
@ -230,6 +203,9 @@ delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.custom-emoji', 'mouseover', ({ target }) => target.src = target.getAttribute('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', ({ target }) => target.src = target.getAttribute('data-static'));
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
|
@ -188,6 +188,7 @@
|
||||
}
|
||||
|
||||
.information-badge,
|
||||
.simple_form .overridden,
|
||||
.simple_form .recommended,
|
||||
.simple_form .not_recommended {
|
||||
display: inline-block;
|
||||
@ -204,6 +205,7 @@
|
||||
}
|
||||
|
||||
.information-badge,
|
||||
.simple_form .overridden,
|
||||
.simple_form .recommended,
|
||||
.simple_form .not_recommended {
|
||||
background-color: rgba($ui-secondary-color, 0.1);
|
||||
|
@ -143,6 +143,11 @@ $content-width: 840px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning a {
|
||||
color: $gold-star;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.simple-navigation-active-leaf a {
|
||||
color: $primary-text-color;
|
||||
background-color: $ui-highlight-color;
|
||||
|
@ -2396,6 +2396,7 @@ $ui-header-height: 55px;
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__textarea {
|
||||
@ -3285,6 +3286,22 @@ $ui-header-height: 55px;
|
||||
border-color: $ui-highlight-color;
|
||||
}
|
||||
|
||||
.switch-to-advanced {
|
||||
color: $classic-primary-color;
|
||||
background-color: $classic-base-color;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
|
||||
.switch-to-advanced__toggle {
|
||||
color: $ui-button-tertiary-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.column-link {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
color: $primary-text-color;
|
||||
@ -5010,6 +5027,12 @@ a.status-card {
|
||||
}
|
||||
|
||||
&__menu {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: $dark-text-color;
|
||||
padding: 0 10px;
|
||||
@ -8856,7 +8879,8 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
.dismissable-banner {
|
||||
.dismissable-banner,
|
||||
.warning-banner {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
margin-bottom: 5px;
|
||||
@ -8934,6 +8958,21 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
border: 1px solid $warning-red;
|
||||
background: rgba($warning-red, 0.15);
|
||||
|
||||
&__message {
|
||||
h1 {
|
||||
color: $warning-red;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -9321,19 +9360,24 @@ noscript {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
gap: 4px;
|
||||
color: $darker-text-color;
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
color: $dark-text-color;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
&:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.link-button {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
@ -103,6 +103,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.overridden,
|
||||
.recommended,
|
||||
.not_recommended {
|
||||
position: absolute;
|
||||
@ -1185,14 +1186,14 @@ code {
|
||||
}
|
||||
|
||||
li:first-child .label {
|
||||
left: auto;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
text-align: start;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
li:last-child .label {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
text-align: end;
|
||||
transform: none;
|
||||
|
@ -12,6 +12,11 @@
|
||||
border-top: 1px solid $ui-base-color;
|
||||
text-align: start;
|
||||
background: darken($ui-base-color, 4%);
|
||||
|
||||
&.critical {
|
||||
font-weight: 700;
|
||||
color: $gold-star;
|
||||
}
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
|
@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||
protected
|
||||
|
||||
def perform_query
|
||||
[mastodon_version, ruby_version, postgresql_version, redis_version]
|
||||
[mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact
|
||||
end
|
||||
|
||||
def mastodon_version
|
||||
@ -57,6 +57,22 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||
}
|
||||
end
|
||||
|
||||
def elasticsearch_version
|
||||
return unless Chewy.enabled?
|
||||
|
||||
client_info = Chewy.client.info
|
||||
version = client_info.dig('version', 'number')
|
||||
|
||||
{
|
||||
key: 'elasticsearch',
|
||||
human_key: client_info.dig('version', 'distribution') == 'opensearch' ? 'OpenSearch' : 'Elasticsearch',
|
||||
value: version,
|
||||
human_value: version,
|
||||
}
|
||||
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def redis_info
|
||||
@redis_info ||= if redis.is_a?(Redis::Namespace)
|
||||
redis.redis.info
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class Admin::SystemCheck
|
||||
ACTIVE_CHECKS = [
|
||||
Admin::SystemCheck::SoftwareVersionCheck,
|
||||
Admin::SystemCheck::MediaPrivacyCheck,
|
||||
Admin::SystemCheck::DatabaseSchemaCheck,
|
||||
Admin::SystemCheck::SidekiqProcessCheck,
|
||||
|
@ -6,6 +6,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
AccountsIndex,
|
||||
TagsIndex,
|
||||
StatusesIndex,
|
||||
PublicStatusesIndex,
|
||||
].freeze
|
||||
|
||||
def skip?
|
||||
@ -85,7 +86,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
|
||||
def mismatched_indexes
|
||||
@mismatched_indexes ||= INDEXES.filter_map do |klass|
|
||||
klass.index_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
|
||||
klass.base_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
|
||||
end
|
||||
end
|
||||
|
||||
|
27
app/lib/admin/system_check/software_version_check.rb
Normal file
27
app/lib/admin/system_check/software_version_check.rb
Normal file
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck
|
||||
include RoutingHelper
|
||||
|
||||
def skip?
|
||||
!current_user.can?(:view_devops) || !SoftwareUpdate.check_enabled?
|
||||
end
|
||||
|
||||
def pass?
|
||||
software_updates.empty?
|
||||
end
|
||||
|
||||
def message
|
||||
if software_updates.any?(&:urgent?)
|
||||
Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true)
|
||||
else
|
||||
Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def software_updates
|
||||
@software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? }
|
||||
end
|
||||
end
|
@ -4,10 +4,10 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
|
||||
def import!
|
||||
scope.includes(:account_stat).find_in_batches(batch_size: @batch_size) do |tmp|
|
||||
in_work_unit(tmp) do |accounts|
|
||||
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body
|
||||
bulk = build_bulk_body(accounts)
|
||||
|
||||
indexed = bulk.count { |entry| entry[:index] }
|
||||
deleted = bulk.count { |entry| entry[:delete] }
|
||||
indexed = bulk.size
|
||||
deleted = 0
|
||||
|
||||
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
|
||||
|
||||
|
@ -68,6 +68,14 @@ class Importer::BaseImporter
|
||||
|
||||
protected
|
||||
|
||||
def build_bulk_body(to_import)
|
||||
# Specialize `Chewy::Index::Import::BulkBuilder#bulk_body` to avoid a few
|
||||
# inefficiencies, as none of our fields or join fields and we do not need
|
||||
# `BulkBuilder`'s versatility.
|
||||
crutches = Chewy::Index::Crutch::Crutches.new index, to_import
|
||||
to_import.map { |object| { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } } }
|
||||
end
|
||||
|
||||
def in_work_unit(...)
|
||||
work_unit = Concurrent::Promises.future_on(@executor, ...)
|
||||
|
||||
|
@ -4,10 +4,10 @@ class Importer::InstancesIndexImporter < Importer::BaseImporter
|
||||
def import!
|
||||
index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
|
||||
in_work_unit(tmp) do |instances|
|
||||
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: instances).bulk_body
|
||||
bulk = build_bulk_body(instances)
|
||||
|
||||
indexed = bulk.count { |entry| entry[:index] }
|
||||
deleted = bulk.count { |entry| entry[:delete] }
|
||||
indexed = bulk.size
|
||||
deleted = 0
|
||||
|
||||
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
|
||||
|
||||
|
32
app/lib/importer/public_statuses_index_importer.rb
Normal file
32
app/lib/importer/public_statuses_index_importer.rb
Normal file
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Importer::PublicStatusesIndexImporter < Importer::BaseImporter
|
||||
def import!
|
||||
scope.select(:id).find_in_batches(batch_size: @batch_size) do |batch|
|
||||
in_work_unit(batch.pluck(:id)) do |status_ids|
|
||||
bulk = ActiveRecord::Base.connection_pool.with_connection do
|
||||
build_bulk_body(index.adapter.default_scope.where(id: status_ids))
|
||||
end
|
||||
|
||||
indexed = bulk.size
|
||||
deleted = 0
|
||||
|
||||
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
|
||||
|
||||
[indexed, deleted]
|
||||
end
|
||||
end
|
||||
|
||||
wait!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def index
|
||||
PublicStatusesIndex
|
||||
end
|
||||
|
||||
def scope
|
||||
Status.indexable
|
||||
end
|
||||
end
|
@ -13,31 +13,24 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter
|
||||
|
||||
scope.find_in_batches(batch_size: @batch_size) do |tmp|
|
||||
in_work_unit(tmp.map(&:status_id)) do |status_ids|
|
||||
bulk = ActiveRecord::Base.connection_pool.with_connection do
|
||||
Chewy::Index::Import::BulkBuilder.new(index, to_index: Status.includes(:media_attachments, :preloadable_poll).where(id: status_ids)).bulk_body
|
||||
end
|
||||
|
||||
indexed = 0
|
||||
deleted = 0
|
||||
|
||||
# We can't use the delete_if proc to do the filtering because delete_if
|
||||
# is called before rendering the data and we need to filter based
|
||||
# on the results of the filter, so this filtering happens here instead
|
||||
bulk.map! do |entry|
|
||||
new_entry = if entry[:index] && entry.dig(:index, :data, 'searchable_by').blank?
|
||||
{ delete: entry[:index].except(:data) }
|
||||
else
|
||||
entry
|
||||
end
|
||||
|
||||
if new_entry[:index]
|
||||
indexed += 1
|
||||
else
|
||||
bulk = ActiveRecord::Base.connection_pool.with_connection do
|
||||
to_index = index.adapter.default_scope.where(id: status_ids)
|
||||
crutches = Chewy::Index::Crutch::Crutches.new index, to_index
|
||||
to_index.map do |object|
|
||||
# This is unlikely to happen, but the post may have been
|
||||
# un-interacted with since it was queued for indexing
|
||||
if object.searchable_by.empty?
|
||||
deleted += 1
|
||||
{ delete: { _id: object.id } }
|
||||
else
|
||||
{ index: { _id: object.id, data: index.compose(object, crutches, fields: []) } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
new_entry
|
||||
end
|
||||
indexed = bulk.size - deleted
|
||||
|
||||
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
|
||||
|
||||
|
@ -4,10 +4,10 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
|
||||
def import!
|
||||
index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
|
||||
in_work_unit(tmp) do |tags|
|
||||
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body
|
||||
bulk = build_bulk_body(tags)
|
||||
|
||||
indexed = bulk.count { |entry| entry[:index] }
|
||||
deleted = bulk.count { |entry| entry[:delete] }
|
||||
indexed = bulk.size
|
||||
deleted = 0
|
||||
|
||||
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PlainTextFormatter
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+}
|
||||
|
||||
attr_reader :text, :local
|
||||
@ -18,7 +16,10 @@ class PlainTextFormatter
|
||||
if local?
|
||||
text
|
||||
else
|
||||
html_entities.decode(strip_tags(insert_newlines)).chomp
|
||||
node = Nokogiri::HTML.fragment(insert_newlines)
|
||||
# Elements that are entirely removed with our Sanitize config
|
||||
node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
|
||||
node.text.chomp
|
||||
end
|
||||
end
|
||||
|
||||
@ -27,8 +28,4 @@ class PlainTextFormatter
|
||||
def insert_newlines
|
||||
text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
|
||||
end
|
||||
|
||||
def html_entities
|
||||
HTMLEntities.new
|
||||
end
|
||||
end
|
||||
|
@ -6,10 +6,10 @@ class SearchQueryParser < Parslet::Parser
|
||||
rule(:colon) { str(':') }
|
||||
rule(:space) { match('\s').repeat(1) }
|
||||
rule(:operator) { (str('+') | str('-')).as(:operator) }
|
||||
rule(:prefix) { (term >> colon).as(:prefix) }
|
||||
rule(:prefix) { term >> colon }
|
||||
rule(:shortcode) { (colon >> term >> colon.maybe).as(:shortcode) }
|
||||
rule(:phrase) { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) }
|
||||
rule(:clause) { (prefix.maybe >> operator.maybe >> (phrase | term | shortcode)).as(:clause) }
|
||||
rule(:clause) { (operator.maybe >> prefix.maybe.as(:prefix) >> (phrase | term | shortcode)).as(:clause) | prefix.as(:clause) | quote.as(:junk) }
|
||||
rule(:query) { (clause >> space.maybe).repeat.as(:query) }
|
||||
root(:query)
|
||||
end
|
||||
|
@ -1,58 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchQueryTransformer < Parslet::Transform
|
||||
SUPPORTED_PREFIXES = %w(
|
||||
has
|
||||
is
|
||||
language
|
||||
from
|
||||
before
|
||||
after
|
||||
during
|
||||
).freeze
|
||||
|
||||
class Query
|
||||
attr_reader :should_clauses, :must_not_clauses, :must_clauses, :filter_clauses
|
||||
attr_reader :must_not_clauses, :must_clauses, :filter_clauses
|
||||
|
||||
def initialize(clauses)
|
||||
grouped = clauses.chunk(&:operator).to_h
|
||||
@should_clauses = grouped.fetch(:should, [])
|
||||
grouped = clauses.compact.chunk(&:operator).to_h
|
||||
@must_not_clauses = grouped.fetch(:must_not, [])
|
||||
@must_clauses = grouped.fetch(:must, [])
|
||||
@filter_clauses = grouped.fetch(:filter, [])
|
||||
end
|
||||
|
||||
def apply(search)
|
||||
should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) }
|
||||
must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) }
|
||||
must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) }
|
||||
filter_clauses.each { |clause| search = search.filter(**clause_to_filter(clause)) }
|
||||
must_clauses.each { |clause| search = search.query.must(clause.to_query) }
|
||||
must_not_clauses.each { |clause| search = search.query.must_not(clause.to_query) }
|
||||
filter_clauses.each { |clause| search = search.filter(**clause.to_query) }
|
||||
search.query.minimum_should_match(1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clause_to_query(clause)
|
||||
case clause
|
||||
when TermClause
|
||||
{ multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } }
|
||||
when PhraseClause
|
||||
{ match_phrase: { text: { query: clause.phrase } } }
|
||||
else
|
||||
raise "Unexpected clause type: #{clause}"
|
||||
end
|
||||
end
|
||||
|
||||
def clause_to_filter(clause)
|
||||
case clause
|
||||
when PrefixClause
|
||||
{ term: { clause.filter => clause.term } }
|
||||
else
|
||||
raise "Unexpected clause type: #{clause}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Operator
|
||||
class << self
|
||||
def symbol(str)
|
||||
case str
|
||||
when '+'
|
||||
when '+', nil
|
||||
:must
|
||||
when '-'
|
||||
:must_not
|
||||
when nil
|
||||
:should
|
||||
else
|
||||
raise "Unknown operator: #{str}"
|
||||
end
|
||||
@ -61,42 +45,106 @@ class SearchQueryTransformer < Parslet::Transform
|
||||
end
|
||||
|
||||
class TermClause
|
||||
attr_reader :prefix, :operator, :term
|
||||
attr_reader :operator, :term
|
||||
|
||||
def initialize(prefix, operator, term)
|
||||
@prefix = prefix
|
||||
def initialize(operator, term)
|
||||
@operator = Operator.symbol(operator)
|
||||
@term = term
|
||||
end
|
||||
|
||||
def to_query
|
||||
{ multi_match: { type: 'most_fields', query: @term, fields: ['text', 'text.stemmed'], operator: 'and' } }
|
||||
end
|
||||
end
|
||||
|
||||
class PhraseClause
|
||||
attr_reader :prefix, :operator, :phrase
|
||||
attr_reader :operator, :phrase
|
||||
|
||||
def initialize(prefix, operator, phrase)
|
||||
@prefix = prefix
|
||||
def initialize(operator, phrase)
|
||||
@operator = Operator.symbol(operator)
|
||||
@phrase = phrase
|
||||
end
|
||||
|
||||
def to_query
|
||||
{ match_phrase: { text: { query: @phrase } } }
|
||||
end
|
||||
end
|
||||
|
||||
class PrefixClause
|
||||
attr_reader :filter, :operator, :term
|
||||
attr_reader :operator, :prefix, :term
|
||||
|
||||
def initialize(prefix, term)
|
||||
def initialize(prefix, operator, term, options = {})
|
||||
@prefix = prefix
|
||||
@negated = operator == '-'
|
||||
@options = options
|
||||
@operator = :filter
|
||||
|
||||
case prefix
|
||||
when 'has', 'is'
|
||||
@filter = :properties
|
||||
@type = :term
|
||||
@term = term
|
||||
when 'language'
|
||||
@filter = :language
|
||||
@type = :term
|
||||
@term = language_code_from_term(term)
|
||||
when 'from'
|
||||
@filter = :account_id
|
||||
@type = :term
|
||||
@term = account_id_from_term(term)
|
||||
when 'before'
|
||||
@filter = :created_at
|
||||
@type = :range
|
||||
@term = { lt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
|
||||
when 'after'
|
||||
@filter = :created_at
|
||||
@type = :range
|
||||
@term = { gt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
|
||||
when 'during'
|
||||
@filter = :created_at
|
||||
@type = :range
|
||||
@term = { gte: term, lte: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
|
||||
else
|
||||
raise "Unknown prefix: #{prefix}"
|
||||
end
|
||||
end
|
||||
|
||||
def to_query
|
||||
if @negated
|
||||
{ bool: { must_not: { @type => { @filter => @term } } } }
|
||||
else
|
||||
{ @type => { @filter => @term } }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_id_from_term(term)
|
||||
return @options[:current_account]&.id || -1 if term == 'me'
|
||||
|
||||
username, domain = term.gsub(/\A@/, '').split('@')
|
||||
domain = nil if TagManager.instance.local_domain?(domain)
|
||||
account = Account.find_remote!(username, domain)
|
||||
account = Account.find_remote(username, domain)
|
||||
|
||||
@term = account.id
|
||||
else
|
||||
raise Mastodon::SyntaxError
|
||||
# If the account is not found, we want to return empty results, so return
|
||||
# an ID that does not exist
|
||||
account&.id || -1
|
||||
end
|
||||
|
||||
def language_code_from_term(term)
|
||||
language_code = term
|
||||
|
||||
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
|
||||
|
||||
language_code = term.downcase
|
||||
|
||||
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
|
||||
|
||||
language_code = term.split(/[_-]/).first.downcase
|
||||
|
||||
return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)
|
||||
|
||||
term
|
||||
end
|
||||
end
|
||||
|
||||
@ -104,18 +152,26 @@ class SearchQueryTransformer < Parslet::Transform
|
||||
prefix = clause[:prefix][:term].to_s if clause[:prefix]
|
||||
operator = clause[:operator]&.to_s
|
||||
|
||||
if clause[:prefix]
|
||||
PrefixClause.new(prefix, clause[:term].to_s)
|
||||
if clause[:prefix] && SUPPORTED_PREFIXES.include?(prefix)
|
||||
PrefixClause.new(prefix, operator, clause[:term].to_s, current_account: current_account)
|
||||
elsif clause[:prefix]
|
||||
TermClause.new(operator, "#{prefix} #{clause[:term]}")
|
||||
elsif clause[:term]
|
||||
TermClause.new(prefix, operator, clause[:term].to_s)
|
||||
TermClause.new(operator, clause[:term].to_s)
|
||||
elsif clause[:shortcode]
|
||||
TermClause.new(prefix, operator, ":#{clause[:term]}:")
|
||||
TermClause.new(operator, ":#{clause[:term]}:")
|
||||
elsif clause[:phrase]
|
||||
PhraseClause.new(prefix, operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
|
||||
PhraseClause.new(operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
|
||||
else
|
||||
raise "Unexpected clause type: #{clause}"
|
||||
end
|
||||
end
|
||||
|
||||
rule(query: sequence(:clauses)) { Query.new(clauses) }
|
||||
rule(junk: subtree(:junk)) do
|
||||
nil
|
||||
end
|
||||
|
||||
rule(query: sequence(:clauses)) do
|
||||
Query.new(clauses)
|
||||
end
|
||||
end
|
||||
|
@ -20,7 +20,10 @@ class Vacuum::StatusesVacuum
|
||||
statuses.direct_visibility
|
||||
.includes(mentions: :account)
|
||||
.find_each(&:unlink_from_conversations!)
|
||||
remove_from_search_index(statuses.ids) if Chewy.enabled?
|
||||
if Chewy.enabled?
|
||||
remove_from_index(statuses.ids, 'chewy:queue:StatusesIndex')
|
||||
remove_from_index(statuses.ids, 'chewy:queue:PublicStatusesIndex')
|
||||
end
|
||||
|
||||
# Foreign keys take care of most associated records for us.
|
||||
# Media attachments will be orphaned.
|
||||
@ -38,7 +41,7 @@ class Vacuum::StatusesVacuum
|
||||
Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false)
|
||||
end
|
||||
|
||||
def remove_from_search_index(status_ids)
|
||||
with_redis { |redis| redis.sadd('chewy:queue:StatusesIndex', status_ids) }
|
||||
def remove_from_index(status_ids, index)
|
||||
with_redis { |redis| redis.sadd(index, status_ids) }
|
||||
end
|
||||
end
|
||||
|
@ -45,6 +45,22 @@ class AdminMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def new_software_updates
|
||||
locale_for_account(@me) do
|
||||
mail subject: default_i18n_subject(instance: @instance)
|
||||
end
|
||||
end
|
||||
|
||||
def new_critical_software_updates
|
||||
headers['Priority'] = 'urgent'
|
||||
headers['X-Priority'] = '1'
|
||||
headers['Importance'] = 'high'
|
||||
|
||||
locale_for_account(@me) do
|
||||
mail subject: default_i18n_subject(instance: @instance)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_params
|
||||
|
@ -82,6 +82,7 @@ class Account < ApplicationRecord
|
||||
include DomainMaterializable
|
||||
include AccountMerging
|
||||
include AccountSearch
|
||||
include AccountStatusesSearch
|
||||
|
||||
MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
|
||||
MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
|
||||
|
44
app/models/concerns/account_statuses_search.rb
Normal file
44
app/models/concerns/account_statuses_search.rb
Normal file
@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountStatusesSearch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_update_commit :enqueue_update_public_statuses_index, if: :saved_change_to_indexable?
|
||||
after_destroy_commit :enqueue_remove_from_public_statuses_index, if: :indexable?
|
||||
end
|
||||
|
||||
def enqueue_update_public_statuses_index
|
||||
if indexable?
|
||||
enqueue_add_to_public_statuses_index
|
||||
else
|
||||
enqueue_remove_from_public_statuses_index
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_add_to_public_statuses_index
|
||||
return unless Chewy.enabled?
|
||||
|
||||
AddToPublicStatusesIndexWorker.perform_async(id)
|
||||
end
|
||||
|
||||
def enqueue_remove_from_public_statuses_index
|
||||
return unless Chewy.enabled?
|
||||
|
||||
RemoveFromPublicStatusesIndexWorker.perform_async(id)
|
||||
end
|
||||
|
||||
def add_to_public_statuses_index!
|
||||
return unless Chewy.enabled?
|
||||
|
||||
statuses.without_reblogs.where(visibility: :public).find_in_batches do |batch|
|
||||
PublicStatusesIndex.import(batch)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_public_statuses_index!
|
||||
return unless Chewy.enabled?
|
||||
|
||||
PublicStatusesIndex.filter(term: { account_id: id }).delete_all
|
||||
end
|
||||
end
|
48
app/models/concerns/status_search_concern.rb
Normal file
48
app/models/concerns/status_search_concern.rb
Normal file
@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module StatusSearchConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :indexable, -> { without_reblogs.where(visibility: :public).joins(:account).where(account: { indexable: true }) }
|
||||
end
|
||||
|
||||
def searchable_by
|
||||
@searchable_by ||= begin
|
||||
ids = []
|
||||
|
||||
ids << account_id if local?
|
||||
|
||||
ids += local_mentioned.pluck(:id)
|
||||
ids += local_favorited.pluck(:id)
|
||||
ids += local_reblogged.pluck(:id)
|
||||
ids += local_bookmarked.pluck(:id)
|
||||
ids += preloadable_poll.local_voters.pluck(:id) if preloadable_poll.present?
|
||||
|
||||
ids.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def searchable_text
|
||||
[
|
||||
spoiler_text,
|
||||
FormattingHelper.extract_status_plain_text(self),
|
||||
preloadable_poll&.options&.join("\n\n"),
|
||||
ordered_media_attachments.map(&:description).join("\n\n"),
|
||||
].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def searchable_properties
|
||||
[].tap do |properties|
|
||||
properties << 'image' if ordered_media_attachments.any?(&:image?)
|
||||
properties << 'video' if ordered_media_attachments.any?(&:video?)
|
||||
properties << 'audio' if ordered_media_attachments.any?(&:audio?)
|
||||
properties << 'media' if with_media?
|
||||
properties << 'poll' if with_poll?
|
||||
properties << 'link' if with_preview_card?
|
||||
properties << 'embed' if preview_cards.any?(&:video?)
|
||||
properties << 'sensitive' if sensitive?
|
||||
properties << 'reply' if reply?
|
||||
end
|
||||
end
|
||||
end
|
@ -3,6 +3,8 @@
|
||||
class Form::AdminSettings
|
||||
include ActiveModel::Model
|
||||
|
||||
include AuthorizedFetchHelper
|
||||
|
||||
KEYS = %i(
|
||||
site_contact_username
|
||||
site_contact_email
|
||||
@ -42,6 +44,7 @@ class Form::AdminSettings
|
||||
backups_retention_period
|
||||
status_page_url
|
||||
captcha_enabled
|
||||
authorized_fetch
|
||||
).freeze
|
||||
|
||||
INTEGER_KEYS = %i(
|
||||
@ -66,6 +69,7 @@ class Form::AdminSettings
|
||||
noindex
|
||||
require_invite_text
|
||||
captcha_enabled
|
||||
authorized_fetch
|
||||
).freeze
|
||||
|
||||
UPLOAD_KEYS = %i(
|
||||
@ -77,6 +81,10 @@ class Form::AdminSettings
|
||||
flavour_and_skin
|
||||
).freeze
|
||||
|
||||
OVERRIDEN_SETTINGS = {
|
||||
authorized_fetch: :authorized_fetch_mode?,
|
||||
}.freeze
|
||||
|
||||
attr_accessor(*KEYS)
|
||||
|
||||
validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
|
||||
@ -96,6 +104,8 @@ class Form::AdminSettings
|
||||
|
||||
stored_value = if UPLOAD_KEYS.include?(key)
|
||||
SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
elsif OVERRIDEN_SETTINGS.include?(key)
|
||||
public_send(OVERRIDEN_SETTINGS[key])
|
||||
else
|
||||
Setting.public_send(key)
|
||||
end
|
||||
|
@ -44,6 +44,7 @@ class MediaAttachment < ApplicationRecord
|
||||
|
||||
MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
|
||||
MAX_VIDEO_FRAME_RATE = 120
|
||||
MAX_VIDEO_FRAMES = 36_000 # Approx. 5 minutes at 120 fps
|
||||
|
||||
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze
|
||||
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
|
||||
@ -98,17 +99,15 @@ class MediaAttachment < ApplicationRecord
|
||||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
'movflags' => 'faststart',
|
||||
'pix_fmt' => 'yuv420p',
|
||||
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
|
||||
'vsync' => 'cfr',
|
||||
'preset' => 'veryfast',
|
||||
'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
|
||||
'pix_fmt' => 'yuv420p', # Ensure color space for cross-browser compatibility
|
||||
'vf' => 'crop=floor(iw/2)*2:floor(ih/2)*2', # h264 requires width and height to be even. Crop instead of scale to avoid blurring
|
||||
'c:v' => 'h264',
|
||||
'maxrate' => '1300K',
|
||||
'bufsize' => '1300K',
|
||||
'b:v' => '1300K',
|
||||
'frames:v' => 60 * 60 * 3,
|
||||
'crf' => 18,
|
||||
'c:a' => 'aac',
|
||||
'b:a' => '192k',
|
||||
'map_metadata' => '-1',
|
||||
'frames:v' => MAX_VIDEO_FRAMES,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
@ -135,7 +134,7 @@ class MediaAttachment < ApplicationRecord
|
||||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
:vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
:vf => 'scale=\'min(640\, iw):min(640\, ih)\':force_original_aspect_ratio=decrease',
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
format: 'png',
|
||||
|
@ -28,6 +28,7 @@ class Poll < ApplicationRecord
|
||||
|
||||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
|
||||
has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account
|
||||
has_many :local_voters, -> { group('accounts.id').merge(Account.local) }, through: :votes, class_name: 'Account', source: :account
|
||||
|
||||
has_many :notifications, as: :activity, dependent: :destroy
|
||||
|
||||
|
40
app/models/software_update.rb
Normal file
40
app/models/software_update.rb
Normal file
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: software_updates
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# version :string not null
|
||||
# urgent :boolean default(FALSE), not null
|
||||
# type :integer default("patch"), not null
|
||||
# release_notes :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class SoftwareUpdate < ApplicationRecord
|
||||
self.inheritance_column = nil
|
||||
|
||||
enum type: { patch: 0, minor: 1, major: 2 }, _suffix: :type
|
||||
|
||||
def gem_version
|
||||
Gem::Version.new(version)
|
||||
end
|
||||
|
||||
class << self
|
||||
def check_enabled?
|
||||
ENV['UPDATE_CHECK_URL'] != ''
|
||||
end
|
||||
|
||||
def pending_to_a
|
||||
return [] unless check_enabled?
|
||||
|
||||
all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version }
|
||||
end
|
||||
|
||||
def urgent_pending?
|
||||
pending_to_a.any?(&:urgent?)
|
||||
end
|
||||
end
|
||||
end
|
@ -39,6 +39,7 @@ class Status < ApplicationRecord
|
||||
include StatusSnapshotConcern
|
||||
include RateLimitable
|
||||
include StatusSafeReblogInsert
|
||||
include StatusSearchConcern
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
@ -49,6 +50,7 @@ class Status < ApplicationRecord
|
||||
attr_accessor :override_timestamps
|
||||
|
||||
update_index('statuses', :proper)
|
||||
update_index('public_statuses', :proper)
|
||||
|
||||
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _suffix: :visibility
|
||||
|
||||
@ -73,6 +75,12 @@ class Status < ApplicationRecord
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
has_many :status_reactions, inverse_of: :status, dependent: :destroy
|
||||
|
||||
# Those associations are used for the private search index
|
||||
has_many :local_mentioned, -> { merge(Account.local) }, through: :active_mentions, source: :account
|
||||
has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account
|
||||
has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account
|
||||
has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account
|
||||
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
|
||||
@ -173,37 +181,6 @@ class Status < ApplicationRecord
|
||||
"v3:#{super}"
|
||||
end
|
||||
|
||||
def searchable_by(preloaded = nil)
|
||||
ids = []
|
||||
|
||||
ids << account_id if local?
|
||||
|
||||
if preloaded.nil?
|
||||
ids += mentions.joins(:account).merge(Account.local).active.pluck(:account_id)
|
||||
ids += favourites.joins(:account).merge(Account.local).pluck(:account_id)
|
||||
ids += reblogs.joins(:account).merge(Account.local).pluck(:account_id)
|
||||
ids += bookmarks.joins(:account).merge(Account.local).pluck(:account_id)
|
||||
ids += poll.votes.joins(:account).merge(Account.local).pluck(:account_id) if poll.present?
|
||||
else
|
||||
ids += preloaded.mentions[id] || []
|
||||
ids += preloaded.favourites[id] || []
|
||||
ids += preloaded.reblogs[id] || []
|
||||
ids += preloaded.bookmarks[id] || []
|
||||
ids += preloaded.votes[id] || []
|
||||
end
|
||||
|
||||
ids.uniq
|
||||
end
|
||||
|
||||
def searchable_text
|
||||
[
|
||||
spoiler_text,
|
||||
FormattingHelper.extract_status_plain_text(self),
|
||||
preloadable_poll ? preloadable_poll.options.join("\n\n") : nil,
|
||||
ordered_media_attachments.map(&:description).join("\n\n"),
|
||||
].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
account.acct
|
||||
end
|
||||
@ -278,6 +255,10 @@ class Status < ApplicationRecord
|
||||
preview_cards.any?
|
||||
end
|
||||
|
||||
def with_poll?
|
||||
preloadable_poll.present?
|
||||
end
|
||||
|
||||
def non_sensitive_with_media?
|
||||
!sensitive? && with_media?
|
||||
end
|
||||
|
@ -53,6 +53,7 @@ class UserSettings
|
||||
setting :link_trends, default: false
|
||||
setting :status_trends, default: false
|
||||
setting :appeal, default: true
|
||||
setting :software_updates, default: 'critical', in: %w(none critical patch all)
|
||||
end
|
||||
|
||||
namespace :interactions do
|
||||
|
7
app/policies/software_update_policy.rb
Normal file
7
app/policies/software_update_policy.rb
Normal file
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SoftwareUpdatePolicy < ApplicationPolicy
|
||||
def index?
|
||||
role.can?(:view_devops)
|
||||
end
|
||||
end
|
@ -3,9 +3,13 @@
|
||||
class InitialStatePresenter < ActiveModelSerializers::Model
|
||||
attributes :settings, :push_subscription, :token,
|
||||
:current_account, :admin, :owner, :text, :visibility,
|
||||
:disabled_account, :moved_to_account
|
||||
:disabled_account, :moved_to_account, :critical_updates_pending
|
||||
|
||||
def role
|
||||
current_account&.user_role
|
||||
end
|
||||
|
||||
def critical_updates_pending
|
||||
role&.can?(:view_devops) && SoftwareUpdate.urgent_pending?
|
||||
end
|
||||
end
|
||||
|
@ -8,13 +8,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
||||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended,
|
||||
:memorial
|
||||
:memorial, :indexable
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured, :featured_tags,
|
||||
:preferred_username, :name, :summary,
|
||||
:url, :manually_approves_followers,
|
||||
:discoverable, :published, :memorial
|
||||
:discoverable, :indexable, :published, :memorial
|
||||
|
||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
||||
|
||||
@ -99,6 +99,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
||||
object.suspended? ? false : (object.discoverable || false)
|
||||
end
|
||||
|
||||
def indexable
|
||||
object.suspended? ? false : (object.indexable || false)
|
||||
end
|
||||
|
||||
def name
|
||||
object.suspended? ? object.username : (object.display_name.presence || object.username)
|
||||
end
|
||||
|
@ -8,6 +8,8 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||
:max_toot_chars, :poll_limits,
|
||||
:languages, :max_reactions
|
||||
|
||||
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
|
||||
|
||||
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||
has_one :role, serializer: REST::RoleSerializer
|
||||
|
||||
|
@ -18,18 +18,31 @@ class WebfingerSerializer < ActiveModel::Serializer
|
||||
end
|
||||
|
||||
def links
|
||||
if object.instance_actor?
|
||||
[
|
||||
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) },
|
||||
{ rel: 'self', type: 'application/activity+json', href: instance_actor_url },
|
||||
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href },
|
||||
{ rel: 'self', type: 'application/activity+json', href: self_href },
|
||||
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
|
||||
]
|
||||
else
|
||||
[
|
||||
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) },
|
||||
{ rel: 'self', type: 'application/activity+json', href: account_url(object) },
|
||||
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
|
||||
]
|
||||
].tap do |x|
|
||||
x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def show_avatar?
|
||||
media_present = object.avatar.present? && object.avatar.content_type.present?
|
||||
|
||||
# Show avatar only if an instance shows profiles to logged out users
|
||||
allowed_by_config = ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] != 'true' && !Rails.configuration.x.limited_federation_mode
|
||||
|
||||
media_present && allowed_by_config
|
||||
end
|
||||
|
||||
def profile_page_href
|
||||
object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
|
||||
end
|
||||
|
||||
def self_href
|
||||
object.instance_actor? ? instance_actor_url : account_url(object)
|
||||
end
|
||||
end
|
||||
|
@ -38,7 +38,10 @@ class BatchedRemoveStatusService < BaseService
|
||||
|
||||
# Since we skipped all callbacks, we also need to manually
|
||||
# deindex the statuses
|
||||
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs) if Chewy.enabled?
|
||||
if Chewy.enabled?
|
||||
Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs)
|
||||
Chewy.strategy.current.update(PublicStatusesIndex, statuses_and_reblogs)
|
||||
end
|
||||
|
||||
return if options[:skip_side_effects]
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Payloadable
|
||||
include AuthorizedFetchHelper
|
||||
|
||||
# @param [ActiveModelSerializers::Model] record
|
||||
# @param [ActiveModelSerializers::Serializer] serializer
|
||||
# @param [Hash] options
|
||||
@ -23,6 +25,6 @@ module Payloadable
|
||||
end
|
||||
|
||||
def signing_enabled?
|
||||
ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.limited_federation_mode
|
||||
!authorized_fetch_mode?
|
||||
end
|
||||
end
|
||||
|
@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchService < BaseService
|
||||
QUOTE_EQUIVALENT_CHARACTERS = /[“”„«»「」『』《》]/
|
||||
|
||||
def call(query, account, limit, options = {})
|
||||
@query = query&.strip
|
||||
@query = query&.strip&.gsub(QUOTE_EQUIVALENT_CHARACTERS, '"')
|
||||
@account = account
|
||||
@options = options
|
||||
@limit = limit.to_i
|
||||
@ -17,7 +19,7 @@ class SearchService < BaseService
|
||||
results.merge!(url_resource_results) unless url_resource.nil? || @offset.positive? || (@options[:type].present? && url_resource_symbol != @options[:type].to_sym)
|
||||
elsif @query.present?
|
||||
results[:accounts] = perform_accounts_search! if account_searchable?
|
||||
results[:statuses] = perform_statuses_search! if full_text_searchable?
|
||||
results[:statuses] = perform_statuses_search! if status_searchable?
|
||||
results[:hashtags] = perform_hashtags_search! if hashtag_searchable?
|
||||
end
|
||||
end
|
||||
@ -39,25 +41,15 @@ class SearchService < BaseService
|
||||
end
|
||||
|
||||
def perform_statuses_search!
|
||||
definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id }))
|
||||
|
||||
definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present?
|
||||
|
||||
if @options[:min_id].present? || @options[:max_id].present?
|
||||
range = {}
|
||||
range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
|
||||
range[:lt] = @options[:max_id].to_i if @options[:max_id].present?
|
||||
definition = definition.filter(range: { id: range })
|
||||
end
|
||||
|
||||
results = definition.limit(@limit).offset(@offset).objects.compact
|
||||
account_ids = results.map(&:account_id)
|
||||
account_domains = results.map(&:account_domain)
|
||||
preloaded_relations = @account.relations_map(account_ids, account_domains)
|
||||
|
||||
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
|
||||
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
||||
[]
|
||||
StatusesSearchService.new.call(
|
||||
@query,
|
||||
@account,
|
||||
limit: @limit,
|
||||
offset: @offset,
|
||||
account_id: @options[:account_id],
|
||||
min_id: @options[:min_id],
|
||||
max_id: @options[:max_id]
|
||||
)
|
||||
end
|
||||
|
||||
def perform_hashtags_search!
|
||||
@ -89,18 +81,16 @@ class SearchService < BaseService
|
||||
url_resource.class.name.downcase.pluralize.to_sym
|
||||
end
|
||||
|
||||
def full_text_searchable?
|
||||
return false unless Chewy.enabled?
|
||||
|
||||
statuses_search? && !@account.nil? && !(@query.include?('@') && !@query.include?(' '))
|
||||
def status_searchable?
|
||||
Chewy.enabled? && status_search? && @account.present?
|
||||
end
|
||||
|
||||
def account_searchable?
|
||||
account_search? && !(@query.include?('@') && @query.include?(' '))
|
||||
account_search?
|
||||
end
|
||||
|
||||
def hashtag_searchable?
|
||||
hashtag_search? && !@query.include?('@')
|
||||
hashtag_search?
|
||||
end
|
||||
|
||||
def account_search?
|
||||
@ -111,11 +101,7 @@ class SearchService < BaseService
|
||||
@options[:type].blank? || @options[:type] == 'hashtags'
|
||||
end
|
||||
|
||||
def statuses_search?
|
||||
def status_search?
|
||||
@options[:type].blank? || @options[:type] == 'statuses'
|
||||
end
|
||||
|
||||
def parsed_query
|
||||
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
|
||||
end
|
||||
end
|
||||
|
82
app/services/software_update_check_service.rb
Normal file
82
app/services/software_update_check_service.rb
Normal file
@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SoftwareUpdateCheckService < BaseService
|
||||
def call
|
||||
clean_outdated_updates!
|
||||
return unless SoftwareUpdate.check_enabled?
|
||||
|
||||
process_update_notices!(fetch_update_notices)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clean_outdated_updates!
|
||||
SoftwareUpdate.find_each do |software_update|
|
||||
software_update.delete if Mastodon::Version.gem_version >= software_update.gem_version
|
||||
rescue ArgumentError
|
||||
software_update.delete
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_update_notices
|
||||
Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res|
|
||||
return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200
|
||||
end
|
||||
rescue HTTP::Error, OpenSSL::SSL::SSLError, Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def api_url
|
||||
ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check')
|
||||
end
|
||||
|
||||
def version
|
||||
@version ||= Mastodon::Version.to_s.split('+')[0]
|
||||
end
|
||||
|
||||
def process_update_notices!(update_notices)
|
||||
return if update_notices.blank? || update_notices['updatesAvailable'].blank?
|
||||
|
||||
# Clear notices that are not listed by the update server anymore
|
||||
SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].pluck('version')).delete_all
|
||||
|
||||
# Check if any of the notices is new, and issue notifications
|
||||
known_versions = SoftwareUpdate.where(version: update_notices['updatesAvailable'].pluck('version')).pluck(:version)
|
||||
new_update_notices = update_notices['updatesAvailable'].filter { |notice| known_versions.exclude?(notice['version']) }
|
||||
return if new_update_notices.blank?
|
||||
|
||||
new_updates = new_update_notices.map do |notice|
|
||||
SoftwareUpdate.create!(version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes'])
|
||||
end
|
||||
|
||||
notify_devops!(new_updates)
|
||||
end
|
||||
|
||||
def should_notify_user?(user, urgent_version, patch_version)
|
||||
case user.settings['notification_emails.software_updates']
|
||||
when 'none'
|
||||
false
|
||||
when 'critical'
|
||||
urgent_version
|
||||
when 'patch'
|
||||
urgent_version || patch_version
|
||||
when 'all'
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def notify_devops!(new_updates)
|
||||
has_new_urgent_version = new_updates.any?(&:urgent?)
|
||||
has_new_patch_version = new_updates.any?(&:patch_type?)
|
||||
|
||||
User.those_who_can(:view_devops).includes(:account).find_each do |user|
|
||||
next unless should_notify_user?(user, has_new_urgent_version, has_new_patch_version)
|
||||
|
||||
if has_new_urgent_version
|
||||
AdminMailer.with(recipient: user.account).new_critical_software_updates.deliver_later
|
||||
else
|
||||
AdminMailer.with(recipient: user.account).new_software_updates.deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user