diff --git a/README.md b/README.md index 0ef555b..e70e205 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,15 @@ If you'd rather run the Docker image yourself, you can read more about that in t ## Other implementations -Campfire also has implementations in Django, Laravel, Elixir, Go and Rust: +Campfire also has implementations in Django, Laravel, Express, Elixir, Go and Rust: -| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | -|---|---:|---:|---:|---:|---:|---:| -| Room page | 242 | 170 | 164 | 722 | 3,860 | 36,260 | -| Messages page | 402 | 196 | 175 | 1,053 | 5,573 | 40,872 | -| Sidebar | 541 | 615 | 715 | 1,275 | 19,753 | 34,672 | -| Search | 424 | 315 | 305 | 1,156 | 7,053 | 33,299 | -| Post a message | 225 | 154 | 137 | 801 | 4,767 | 6,896 | +| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | +|---|---:|---:|---:|---:|---:|---:|---:| +| Room page | 241 | 170 | 164 | 559 | 722 | 3,860 | 36,260 | +| Messages page | 413 | 196 | 175 | 777 | 1,053 | 5,573 | 40,872 | +| Sidebar | 552 | 615 | 715 | 4,125 | 1,275 | 19,753 | 34,672 | +| Search | 435 | 315 | 305 | 1,294 | 1,156 | 7,053 | 33,299 | +| Post a message | 273 | 154 | 137 | 256 | 801 | 4,767 | 6,896 | Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395, with four hardware threads allocated to each app. diff --git a/app/javascript/lib/autocomplete/base_autocomplete_handler.js b/app/javascript/lib/autocomplete/base_autocomplete_handler.js index 28b7fd0..9f1f352 100644 --- a/app/javascript/lib/autocomplete/base_autocomplete_handler.js +++ b/app/javascript/lib/autocomplete/base_autocomplete_handler.js @@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler { #fetchAutocompletables(url) { if (url) { - return fetch(url, { as: "json" }).then(response => response.json()) + return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json()) } else { return Promise.resolve() } diff --git a/app/models/message/pagination.rb b/app/models/message/pagination.rb index 3be7983..0e7690b 100644 --- a/app/models/message/pagination.rb +++ b/app/models/message/pagination.rb @@ -49,7 +49,7 @@ module Message::Pagination end def paged? - count > PAGE_SIZE + offset(PAGE_SIZE).exists? end end end diff --git a/app/models/message/searchable.rb b/app/models/message/searchable.rb index 58ea47f..b792cd0 100644 --- a/app/models/message/searchable.rb +++ b/app/models/message/searchable.rb @@ -6,7 +6,15 @@ module Message::Searchable after_update_commit :update_in_index after_destroy_commit :remove_from_index - scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered } + scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered } + end + + class_methods do + # Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than + # parsing them as operators, which fails on a query like "AND" or "salt AND". + def match_terms(query) + query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ") + end end private diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 00f7e5b..23a66c5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -40,9 +40,9 @@
"> <% if flash[:alert] %> - <%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> + <%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> <% else %> - <%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> + <%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> <% end %>
<%= notice %> diff --git a/app/views/messages/boosts/_boosts.html.erb b/app/views/messages/boosts/_boosts.html.erb index 4d0d06f..d1a340a 100644 --- a/app/views/messages/boosts/_boosts.html.erb +++ b/app/views/messages/boosts/_boosts.html.erb @@ -7,7 +7,7 @@ <%= turbo_frame_tag message, :new_boost do %>
- <%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %> + <%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %> <%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %> Add a boost <% end %> diff --git a/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb new file mode 100644 index 0000000..a46b27b --- /dev/null +++ b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb @@ -0,0 +1,5 @@ +class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2] + def change + add_index :messages, %i[ room_id created_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index e7f1540..8a900cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do +ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.text "custom_styles" @@ -101,6 +101,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do t.integer "room_id", null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_messages_on_creator_id" + t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at" t.index ["room_id"], name: "index_messages_on_room_id" end diff --git a/test/controllers/messages/boosts_controller_test.rb b/test/controllers/messages/boosts_controller_test.rb index c04cd55..8cc7de9 100644 --- a/test/controllers/messages/boosts_controller_test.rb +++ b/test/controllers/messages/boosts_controller_test.rb @@ -6,6 +6,13 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest @message = messages(:first) end + test "index wires the new boost link to the soft keyboard" do + get message_boosts_url(@message) + + assert_response :success + assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']" + end + test "create" do assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do assert_difference -> { @message.boosts.count }, 1 do diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 80abfb4..34ff01b 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest assert_select ".message", text: /Hello world!/ end + test "operator words are searched for as words" do + rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david) + + get searches_url, params: { q: "salt AND" } + + assert_response :success + assert_select ".message", text: /Salt and pepper/ + end + + test "searching for an operator word alone doesn't fail" do + %w[ AND OR NOT ].each do |word| + get searches_url, params: { q: word } + + assert_response :success + assert_select ".message", count: 0 + end + end + test "unreachable messages are not found" do memberships(:david_designers).destroy! diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..a5b73f0 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,6 +65,24 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end + test "paged? tells whether a room has more than a page of messages without counting them all" do + room = Rooms::Closed.create!(name: "Paging", creator: users(:david)) + Message.insert_all Array.new(Message::Pagination::PAGE_SIZE) { |i| { room_id: room.id, creator_id: users(:david).id, client_message_id: "paging-#{i}" } } + + assert_no_queries_match(/COUNT/i) do + assert_not room.messages.paged? + + Message.insert_all [ { room_id: room.id, creator_id: users(:david).id, client_message_id: "paging-next" } ] + assert room.messages.paged? + end + end + + test "search reads quotes and operator words in the query as text" do + message = rooms(:designers).messages.create!(body: "Say hi, NOT bye", client_message_id: "quoted", creator: users(:david)) + + assert_equal [ message ], Message.search(%(say "hi NOT)).to_a + end + private def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123") diff --git a/test/system/starting_pings_test.rb b/test/system/starting_pings_test.rb new file mode 100644 index 0000000..ddb5b4f --- /dev/null +++ b/test/system/starting_pings_test.rb @@ -0,0 +1,15 @@ +require "application_system_test_case" + +class StartingPingsTest < ApplicationSystemTestCase + setup do + sign_in "kevin@37signals.com" + end + + test "suggesting people to ping" do + visit new_rooms_direct_url + + find("[data-autocomplete-target='input']").send_keys("Jas") + + assert_selector "suggestion-option[role='option']", text: "Jason" + end +end