Commit Graph

323 Commits

Author SHA1 Message Date
GPT on behalf of DHH 6ad4735b19 Merge pull request #324: Rewrite a message's search entry only when its body or attachment changes
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 819b3896fb Merge pull request #323: Count unread rooms for push badges once per batch
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH b6d307537e Merge pull request #325: Render the boosts a message has preloaded instead of querying them again
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH fdec7dfc9f Merge pull request #322: Cache boosts with their message instead of one by one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0d5998f057 Merge pull request #318: Query sidebar directs and shared rooms separately
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 87eafc025f Merge pull request #310: Find a direct room with one query instead of checking every one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 1e0d353c60 Merge pull request #312: Find the messages a refresh replaces through an index
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH cf63b61361 Merge pull request #334: Create the room and creation time index only where it is missing
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
Marcello Costagliola bf5dc0d74d Create the room and creation time index only where it is missing
The Rust port creates index_messages_on_room_id_and_created_at on boot,
under the same name and on the same columns, with CREATE INDEX IF NOT
EXISTS. On a database the port opened before this migration ran,
db:prepare stopped with "index ... already exists" and the app didn't
start. With if_not_exists the migration skips an index that is already
there and creates it everywhere else.

Databases that already ran the migration don't run it again, and the
dumped schema is the same, so db/schema.rb doesn't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG
2026-10-06 22:00:50 +02:00
Marcello Costagliola 839ea34c29 Create the room and update time index only where it is missing
The Rust port creates index_messages_on_room_id_and_updated_at on boot,
under the same name, with CREATE INDEX IF NOT EXISTS
(basecamp/once-campfire-rust#45). On a database the port has opened,
this migration stopped db:prepare with "index ... already exists" and
the app didn't start. With if_not_exists it skips an index that is
already there and creates it everywhere else, and the dumped schema
stays the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG
2026-10-06 21:55:54 +02:00
Rosa Gutierrez 32b4144b52 Merge pull request #314 from rubys/update-rails-main
Update Rails to main
2026-10-06 13:03:59 +02:00
Cursor Agent d2241f16db Merge remote-tracking branch 'upstream/main' into cursor/split-sidebar-membership-queries-8545
Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 18:30:41 +00:00
Sam Ruby e0c0a1dc79 Check templates with bin/rails herb:check in CI
Rails 8.2 compiles HTML templates through Herb in strict mode, so a
template that doesn't compile fails to render. herb:check boots the app,
which loads ruby-vips, hence libvips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:45:37 -04:00
Sam Ruby 9d75c8b7eb Update Rails to main
Rails main (e3d5c569) moves Campfire's pin forward ten months. Because
Campfire loads the 8.2 framework defaults, the ones added since then take
effect too, among them header-only forgery protection, Herb as the HTML
template engine, strict Accept headers and immediate blob analysis.

Changes it needed:

- Minitest 6 has no minitest/unit; the test helper no longer requires it.
- Rack::Sendfile is no longer in the middleware stack; DebugLocks goes
  before ActionDispatch::Executor, as the Rails guide now says.
- Time::DATE_FORMATS is deprecated; :epoch is registered through
  ActiveSupport::TimeFormats.
- Channel test subscriptions expose stream_names; streams is private.
- sentry-rails declares its Action Cable handle_open and handle_close
  wrappers private, which Rails 8.2 calls from outside the connection, so
  no /cable connection succeeded. An initializer makes them public until
  getsentry/sentry-ruby#2972 ships (issue #2975).
- Lexxy renders editor content through Rails' editor adapter when Rails
  has one, which asks a mention for its editor partial. It is the same
  users/mention partial the mention prompt already inserts.
- redis-client moves to 0.30.1: Rails main's Redis cache store, which
  production uses, requires 0.28.0 or later, and assets:precompile
  (the Docker build) aborted on 0.25.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b4d3880a3d88059dd5b0b884f1f5f1a6f82aa95c)
2026-10-05 11:45:37 -04:00
Sam Ruby 1cb2f69786 Compile the PWA help and account settings through Herb
Rails main compiles HTML templates through Herb under the 8.2 framework
defaults, which Campfire loads. Herb rejects `case` and its first `when`
in a single ERB tag, so the three pwa/ partials failed to compile, and
`herb:check` rejects ERB output in attribute names, which the account
settings' switch used for `checked`. Give `case` its own tag and build
the switch with tag.input; both render the same under Erubi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 244e77241b)
2026-10-05 11:45:06 -04:00
Marcello Costagliola e07da58668 Rewrite the search entry when the attachment is replaced too
An attachment message is indexed by its file name, and the update
action still accepts a new attachment. Active Storage clears
attachment_changes in its own after_commit, which runs before this one,
so the replacement is noted in before_update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:34:06 +02:00
Marcello Costagliola 20275e1fa7 Render the boosts a message has preloaded instead of querying them again
Pages of messages load them with_presentation, which preloads each
message's boosts with their boosters and avatars, but the boosts partial
asked for message.boosts.ordered: a new query for every message, and the
preloaded boosts went unused. Sort the preloaded boosts in Ruby. The
boosts frame, whose message comes without them, still queries them in
order with their boosters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:17:29 +02:00
Stanko Krtalić d2155e85a0 Merge pull request #306 from namespaceMarcello/post-webhook-attachments-only-on-success
Post a webhook reply as an attachment only when the bot answered 200
2026-10-05 17:13:57 +02:00
Stanko Krtalić 5c72c35775 Merge pull request #291 from basecamp/dependabot/bundler/selenium-webdriver-4.49.0
build(deps-dev): bump selenium-webdriver from 4.35.0 to 4.49.0
2026-10-05 17:10:16 +02:00
Marcello Costagliola 78a84a6ab7 Rewrite a message's search entry only when its body is saved
Boosting and unboosting touch the message, so after_update_commit
rewrote its row in the full-text index with the same text, loading the
rich text again to rebuild it, on every boost. The index only needs a
new row when the rich text body was saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 17:09:46 +02:00
Stanko Krtalić 21c49b3ccc Merge pull request #289 from basecamp/dependabot/bundler/faker-3.8.0
build(deps-dev): bump faker from 3.5.2 to 3.8.0
2026-10-05 17:06:49 +02:00
Stanko Krtalić c09c464e9a Merge pull request #288 from basecamp/dependabot/github_actions/github-actions-e631a66382
build(deps): bump ruby/setup-ruby from 1.324.0 to 1.327.0 in the github-actions group
2026-10-05 17:06:28 +02:00
Stanko Krtalić b39766c8d6 Merge pull request #287 from basecamp/dependabot/bundler/propshaft-dc979db
build(deps): bump propshaft from `e49a9de` to `dc979db`
2026-10-05 17:06:15 +02:00
Stanko Krtalić b67a0fb706 Merge pull request #285 from cattekin/test-performance
Hash fixture passwords at minimum `bcrypt` cost
2026-10-05 17:05:57 +02:00
Stanko Krtalić 4690de5057 Merge pull request #301 from rubys/fix-edge-install-icon-path
Find the Edge install icon under images/external
2026-10-05 17:04:04 +02:00
Stanko Krtalić 77c5234cb6 Merge pull request #304 from namespaceMarcello/search-newest-matches-by-rowid
Read the newest search matches off the index instead of sorting them all
2026-10-05 16:59:44 +02:00
Marcello Costagliola d485db6038 Count unread rooms for push badges once per batch
Each push notification carries the subscriber's unread room count as its
badge. The pool built it per subscription, loading the user and counting
their unread memberships: two queries for every subscriber, all in the job
before the deliveries reach the threads. With 1,000 subscribed members the
job spent ~180 ms and 2,000 queries there; with 5,000, a second.

The pool now counts the unread rooms of a whole batch with one grouped query
and hands each subscription its badge; nothing else in the notification needs
the user. The queries still run before the work is posted to the threads,
which run outside the Rails executor. Push::Subscription#notification still
counts by itself when no badge is given, as for the test notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 16:49:59 +02:00
Thomas Klemm 77d4eb0d3a Merge branch 'main' into cursor/split-sidebar-membership-queries-8545 2026-10-05 17:47:46 +03:00
Stanko Krtalić 2393f01ba2 Merge pull request #302 from rubys/fix-layout-stray-spans
Drop two unmatched closing spans from the flash
2026-10-05 16:46:27 +02:00
Stanko Krtalić d1f09b4276 Merge pull request #297 from namespaceMarcello/check-paging-without-counting
Check for a second page of messages without counting the room
2026-10-05 16:45:50 +02:00
Marcello Costagliola c761763c8c Cache boosts with their message instead of one by one
Every boost had its own fragment, nested inside the message fragment that
already holds it. With a cold cache, a read and a write per boost plus a
read_multi and a write_multi per message: a room page of 40 messages with
120 boosts takes ~410 ms instead of ~290 ms. Redis runs without persistence
(config/redis.conf), so every page is cold after a restart.

A boost touches its message, so adding or removing one rewrites the message
fragment anyway. The inner fragments only paid off when a message with many
boosts was drawn again, and there they hid a lookup per booster. The boosters
are now loaded with the boosts, one query per message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 16:40:49 +02:00
Stanko K.R. 259c06bea1 Remove trailing whitespace 2026-10-05 16:30:18 +02:00
Stanko Krtalić 6913afa864 Merge branch 'main' into check-paging-without-counting 2026-10-05 16:18:32 +02:00
Stanko Krtalić c73ea37e0c Merge pull request #300 from rubys/fix-autocomplete-json-accept
Ask for JSON when autocompleting people to ping
2026-10-05 16:17:10 +02:00
Stanko Krtalić e0e31846c9 Merge pull request #299 from rubys/fix-boost-soft-keyboard-action
Wire the inline boost link to the soft keyboard
2026-10-05 16:16:25 +02:00
Stanko Krtalić f2bdfde610 Merge pull request #298 from namespaceMarcello/search-operator-words
Search for operator words instead of parsing them
2026-10-05 16:15:23 +02:00
Marcello Costagliola 288ee9dc5c Merge main, now with the room and creation time index from #295
Both migrations stay; db/schema.rb keeps both indexes and the later version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 15:57:15 +02:00
Stanko Krtalić acef0c71cf Merge pull request #295 from namespaceMarcello/messages-room-created-at-index
Index messages by room and creation time
2026-10-05 15:53:19 +02:00
Cursor Agent d6b3dfd64d Compose sidebar lists from existing room scopes
Directs merge Room.directs and order by recency. Shared rooms reuse
with_ordered_room and without_direct_rooms, with the STI filter coming
from Room.without_directs so the join alias stays rooms.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:48:29 +00:00
Cursor Agent 88fbeedc75 Avoid a double join alias when loading shared sidebar rooms
Chaining without_direct_rooms with with_ordered_room joined rooms as
`room` while still ordering on `rooms.name`. Load shared rooms in one
scope instead.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:36:42 +00:00
Cursor Agent fbeb1ae993 Query sidebar directs and shared rooms separately
Load visible direct memberships ordered by room recency and other
rooms ordered by name, instead of hydrating every membership and
splitting them in Ruby.

Fixes #307.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-05 12:35:35 +00:00
Marcello Costagliola ad541b1c6a Drop the query plan test
Keep the test of what page_updated_since returns, which covers the
rewritten scope; pinning SQLite's plan locks in more than the change
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:59:49 +02:00
David Heinemeier Hansson 345e63718f Update README with latest Campfire benchmark comparison (#315) 2026-10-05 07:52:23 -04:00
Marcello Costagliola e12651240c Drop the query plan test
The index is a schema change; a test that pins SQLite's plan for each
pagination scope locks in more than the change needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:51:56 +02:00
Marcello Costagliola ca626ea7e2 Find the messages a refresh replaces through an index
Every room visit and every reconnection asks for the messages updated
since the page was rendered. That query filtered the room by updated_at
and sorted it by created_at, so SQLite walked every message in the room:
about 220-360 ms in a room of a million messages. An index on
(room_id, updated_at) finds the few updated messages directly.

Sorting on +created_at keeps SQLite on that index once messages are also
indexed by (room_id, created_at): with both, the planner otherwise walks
the room in creation order looking for updated rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:48:53 +02:00
Marcello Costagliola 33c4b726e7 Find a direct room with one query instead of checking every one
Opening a direct room looked for an existing one by loading every direct
room on the account and comparing its member ids in Ruby, two queries per
room, on each click. The cost grows with the account: about 0.3 s at 1,000
direct rooms and 3 s at 10,000. Asking SQL for the room among the first
user's memberships whose member set is exactly the given users finds the
same room in one query, however many direct rooms there are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:06:17 +02:00
Marcello Costagliola 6288a10633 Post a webhook reply as an attachment only when the bot answered 200
A bot's reply becomes a message when the status is 200 and the type is text,
and an attachment otherwise, but the attachment branch never looked at the
status. Whenever a bot's endpoint failed, its error page landed in the room as
a file: a proxy's 502 page as attachment.html, a 404 as attachment.text.

Apply the text branch's 200 check to attachments too. The error reply test
answered without a content type, which skipped the attachment branch, so it
now answers with an HTML error page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 02:58:11 +02:00
Marcello Costagliola edaab3e5d1 Read the newest search matches off the index instead of sorting them all
Search showed the last 100 matches by created_at, so SQLite collected every
message containing the words and sorted them before keeping a page. A common
word in a large account meant sorting most of its history on every search.

Ordering by the full-text index's rowid, which is the message id, lets SQLite
walk the index from the newest match and stop once the page is full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 02:50:48 +02:00
Sam Ruby 955d799d11 Find the Edge install icon under images/external
install-edge.svg lives in app/assets/images/external/, alongside the
other install icons, but the Edge branch of pwa/_install_instructions
asked for it at the top level. Propshaft raises MissingAssetError, so a
browser the useragent gem reports as Edge got a 500 on the profile page
and anywhere else the partial renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam Ruby 1d39b8cbe1 Drop two unmatched closing spans from the flash
The flash icons were followed by `</span>` with no opening tag.
Browsers discard them, so nothing renders differently, but HTML-aware
tools such as Herb report them as errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00