diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index ff135867f9..9439e003e4 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -250,3 +250,116 @@ jobs:
with:
name: e2e-screenshots
path: tmp/screenshots/
+
+ test-search:
+ name: Testing search
+ runs-on: ubuntu-latest
+
+ needs:
+ - build
+
+ services:
+ postgres:
+ image: postgres:14-alpine
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_USER: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ redis:
+ image: redis:7-alpine
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 6379:6379
+
+ elasticsearch:
+ image: docker.elastic.co/elasticsearch/elasticsearch:7.17.13
+ env:
+ discovery.type: single-node
+ xpack.security.enabled: false
+ options: >-
+ --health-cmd "curl http://localhost:9200/_cluster/health"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 10
+ ports:
+ - 9200:9200
+
+ env:
+ DB_HOST: localhost
+ DB_USER: postgres
+ DB_PASS: postgres
+ DISABLE_SIMPLECOV: true
+ RAILS_ENV: test
+ BUNDLE_WITH: test
+ ES_ENABLED: true
+ ES_HOST: localhost
+ ES_PORT: 9200
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby-version:
+ - '3.0'
+ - '3.1'
+ - '.ruby-version'
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: actions/download-artifact@v3
+ with:
+ path: './public'
+ name: ${{ github.sha }}
+
+ - name: Update package index
+ run: sudo apt-get update
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ cache: yarn
+ node-version-file: '.nvmrc'
+
+ - name: Install native Ruby dependencies
+ run: sudo apt-get install -y libicu-dev libidn11-dev
+
+ - name: Install additional system dependencies
+ run: sudo apt-get install -y ffmpeg imagemagick
+
+ - name: Set up bundler cache
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby-version}}
+ bundler-cache: true
+
+ - run: yarn --frozen-lockfile
+
+ - name: Load database schema
+ run: './bin/rails db:create db:schema:load db:seed'
+
+ - run: bundle exec rake spec:search
+
+ - name: Archive logs
+ uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: test-search-logs-${{ matrix.ruby-version }}
+ path: log/
+
+ - name: Archive test screenshots
+ uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: test-search-screenshots
+ path: tmp/screenshots/
diff --git a/.nvmrc b/.nvmrc
index 59ea99ee63..541b047dd0 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-16.20
+20.6
diff --git a/Dockerfile b/Dockerfile
index b22284bbd1..3fe4a62bdf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
-ARG NODE_VERSION="16.20-bookworm-slim"
+ARG NODE_VERSION="20.6-bookworm-slim"
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby
FROM node:${NODE_VERSION} as build
diff --git a/Gemfile.lock b/Gemfile.lock
index 4ba0a09946..9e2fc3be22 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -640,7 +640,7 @@ GEM
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
rspec_chunked (0.6)
- rubocop (1.56.2)
+ rubocop (1.56.3)
base64 (~> 0.1.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
diff --git a/Vagrantfile b/Vagrantfile
index 1117d62fff..4303f8e067 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -76,7 +76,8 @@ path.logs: /var/log/elasticsearch
network.host: 0.0.0.0
http.port: 9200
discovery.seed_hosts: ["localhost"]
-cluster.initial_master_nodes: ["node-1"]' > /etc/elasticsearch/elasticsearch.yml
+cluster.initial_master_nodes: ["node-1"]
+xpack.security.enabled: false' > /etc/elasticsearch/elasticsearch.yml
sudo systemctl restart elasticsearch
diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb
index 1109435507..35c504a7ff 100644
--- a/app/controllers/api/v1/directories_controller.rb
+++ b/app/controllers/api/v1/directories_controller.rb
@@ -16,7 +16,9 @@ class Api::V1::DirectoriesController < Api::BaseController
end
def set_accounts
- @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
+ with_read_replica do
+ @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
+ end
end
def accounts_scope
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 21fd540768..7aea346e6d 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -1,3 +1,7 @@
+import { fromJS } from 'immutable';
+
+import { searchHistory } from 'mastodon/settings';
+
import api from '../api';
import { fetchRelationships } from './accounts';
@@ -15,8 +19,7 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
-export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK';
-export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
+export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export function changeSearch(value) {
return {
@@ -170,16 +173,34 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => {
});
};
-export const clickSearchResult = (q, type) => ({
- type: SEARCH_RESULT_CLICK,
+export const clickSearchResult = (q, type) => (dispatch, getState) => {
+ const previous = getState().getIn(['search', 'recent']);
+ const me = getState().getIn(['meta', 'me']);
+ const current = previous.add(fromJS({ type, q })).takeLast(4);
- result: {
- type,
- q,
- },
+ searchHistory.set(me, current.toJS());
+ dispatch(updateSearchHistory(current));
+};
+
+export const forgetSearchResult = q => (dispatch, getState) => {
+ const previous = getState().getIn(['search', 'recent']);
+ const me = getState().getIn(['meta', 'me']);
+ const current = previous.filterNot(result => result.get('q') === q);
+
+ searchHistory.set(me, current.toJS());
+ dispatch(updateSearchHistory(current));
+};
+
+export const updateSearchHistory = recent => ({
+ type: SEARCH_HISTORY_UPDATE,
+ recent,
});
-export const forgetSearchResult = q => ({
- type: SEARCH_RESULT_FORGET,
- q,
-});
+export const hydrateSearch = () => (dispatch, getState) => {
+ const me = getState().getIn(['meta', 'me']);
+ const history = searchHistory.get(me);
+
+ if (history !== null) {
+ dispatch(updateSearchHistory(history));
+ }
+};
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
index 6b0743439b..682b0f5db7 100644
--- a/app/javascript/mastodon/actions/store.js
+++ b/app/javascript/mastodon/actions/store.js
@@ -2,6 +2,7 @@ import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer';
+import { hydrateSearch } from './search';
export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@@ -20,6 +21,7 @@ export function hydrateStore(rawState) {
});
dispatch(hydrateCompose());
+ dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
};
}
diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx
index 3f642bc74e..103ef57827 100644
--- a/app/javascript/mastodon/features/audio/index.jsx
+++ b/app/javascript/mastodon/features/audio/index.jsx
@@ -205,11 +205,11 @@ class Audio extends PureComponent {
};
toggleMute = () => {
- const muted = !this.state.muted;
+ const muted = !(this.state.muted || this.state.volume === 0);
- this.setState({ muted }, () => {
+ this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
if (this.gainNode) {
- this.gainNode.gain.value = muted ? 0 : this.state.volume;
+ this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
}
});
};
@@ -287,7 +287,7 @@ class Audio extends PureComponent {
const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) {
- this.setState({ volume: x }, () => {
+ this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : x;
}
@@ -466,8 +466,9 @@ class Audio extends PureComponent {
render () {
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
- const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
+ const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
+ const muted = this.state.muted || volume === 0;
let warning;
@@ -557,12 +558,12 @@ class Audio extends PureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
index 53e1db9d49..7e1d8b7609 100644
--- a/app/javascript/mastodon/features/compose/components/search.jsx
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -16,6 +16,17 @@ const messages = defineMessages({
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
});
+const labelForRecentSearch = search => {
+ switch(search.get('type')) {
+ case 'account':
+ return `@${search.get('q')}`;
+ case 'hashtag':
+ return `#${search.get('q')}`;
+ default:
+ return search.get('q');
+ }
+};
+
class Search extends PureComponent {
static contextTypes = {
@@ -187,12 +198,16 @@ class Search extends PureComponent {
};
handleRecentSearchClick = search => {
+ const { onChange } = this.props;
const { router } = this.context;
if (search.get('type') === 'account') {
router.history.push(`/@${search.get('q')}`);
} else if (search.get('type') === 'hashtag') {
router.history.push(`/tags/${search.get('q')}`);
+ } else {
+ onChange(search.get('q'));
+ this._submit(search.get('type'));
}
this._unfocus();
@@ -221,11 +236,15 @@ class Search extends PureComponent {
}
_submit (type) {
- const { onSubmit, openInRoute } = this.props;
+ const { onSubmit, openInRoute, value, onClickSearchResult } = this.props;
const { router } = this.context;
onSubmit(type);
+ if (value) {
+ onClickSearchResult(value, type);
+ }
+
if (openInRoute) {
router.history.push('/search');
}
@@ -243,7 +262,7 @@ class Search extends PureComponent {
const { recent } = this.props;
return recent.toArray().map(search => ({
- label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`,
+ label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search),
@@ -359,7 +378,7 @@ class Search extends PureComponent {
{searchEnabled ? (
{this.defaultOptions.map(({ key, label, action }, i) => (
-