Commit Graph

131 Commits

Author SHA1 Message Date
GPT on behalf of DHH a991adb19d Merge pull request #330: bound attachment preview work
Reviewed and merged by GPT on behalf of DHH. Keep both boost-cache and preview-loading regressions.
2026-10-07 10:33:02 +02:00
GPT on behalf of DHH cbf52805bd Merge pull request #311: Post a message whose attachment can't be previewed instead of failing
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0f2a1da560 Merge pull request #316: List one page of account members at a time
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
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
Marcello Costagliola 5f198146e8 Give a video a poster only once its poster variant is made
ActiveStorage::Preview#processed? is true as soon as ffmpeg's frame is attached, but the poster is a
variant of that frame and can fail on its own: the POST rescues a Vips::Error and leaves the frame
attached. The view then emitted the poster's URL, and every view retried the resize. It now checks the
variant too, from the variant records with_attached_attachment already preloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:34:46 +02:00
Marcello Costagliola 9912e63d69 Bound the work of previewing an attachment
A video's preview and a picture's thumbnail are made inside the request that posts the message, and
nothing bounded how long either could take.

- The video preview filter also selects any frame from 5 seconds on. Rails' filter takes the second
  frame it selects, which a video with a single keyframe and no scene change only gives at its end, so
  ffmpeg decoded all of it.
- TimeLimitedVideoPreviewer gives ffmpeg 10 seconds of wall-clock time, kills it past that, and reports
  a failed preview, so the message is posted without one.
- Pictures and videos above 250 megapixels, or whose size couldn't be read, get no preview: decoding
  costs in proportion to the pixels, however small the file.
- The view shows a preview only if it was made when the message was posted. Its URL used to make it on
  view, so a preview that failed or was skipped would be attempted again on every view. The cached
  presentation's version goes up, so cached messages pick this up.
- A video's poster is made, when the message is posted, at the size the view shows it. The full-size WebP
  made until now wasn't shown anywhere, and encoding it costs in proportion to the frame's pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:20:57 +02:00
Marcello Costagliola 91036c5085 Merge branch 'post-messages-with-unreadable-attachments' into bound-attachment-previews
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:20:56 +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 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
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ć 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
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 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
Marcello Costagliola ce4c05da81 Apply the same user filter to every page of account members
Administrators see banned users in the account settings, but only the
first page counted them: the next pages listed active users alone. A
banned member before the page boundary shifted the offset, so one active
member was never listed. Both pages now share User.visible_to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 15:19:21 +02:00
Marcello Costagliola f864403c2c List one page of account members at a time
Since administrators were grouped apart from members (b52c318), the account
settings page loads every user to split them and renders all of them. The
lazy next page still starts at the 501st user, so on an account with more
than 500 people, scrolling down lists those users a second time.

Load administrators on their own and page only members, both on the settings
page and on the pages that follow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 14:56:22 +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 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 e6de359872 Post a message whose attachment can't be previewed instead of failing
The thumbnail or video preview is generated while the message is posted.
When ffmpeg or libvips can't decode the file (a truncated upload, an .mp4
holding only audio), the error escaped after the message had been saved:
the request failed with a 500, the message was never broadcast, and the
sender's upload stayed at 100% while the rest of the files in that drop
were never sent. Posting the message without a preview keeps the file and
lets everyone see it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2
2026-10-05 04:12:44 +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
Sam Ruby b8be941b99 Ask for JSON when autocompleting people to ping
`as: "json"` is a @rails/request.js option; plain fetch ignores it and
sends `Accept: */*`, so Autocompletable::UsersController answered with
its HTML format and response.json() threw. The new ping form never
showed any suggestions. Send an explicit Accept header instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam Ruby c057484dfb Wire the inline boost link to the soft keyboard
link_to passed `action: "soft-keyboard#open"` as a plain option, so it
rendered as an `action` attribute, which Stimulus never reads. The link
in messages/_actions.html.erb already passes it under `data:`; this one
now does too, so tapping "Add a boost" on a touch device opens the
keyboard for the boost form as the menu's link does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Marcello Costagliola 6cdcb459b9 Search for operator words instead of parsing them
The search box keeps only word characters, but FTS5 still reads AND, OR,
NOT and NEAR in the query as operators, so searching for "AND" or for
"salt AND" raised "fts5: syntax error" and answered with a 500. Quoting
each word makes FTS5 look for it as text, which is how it already treats
the same words in lower case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:08:53 +02:00
Marcello Costagliola cf0ba58d8d Check for a second page of messages without counting the room
The original room shows its welcome box until it holds more than a page of
messages, and paged? answered that with a COUNT(*) over every message in
the room on each visit. Asking whether there is a row past the first page
answers the same question while reading at most a page of index entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:03:39 +02:00
David Heinemeier Hansson 659f95748a Preload only uncached messages and reduce rendering overhead (#292)
* Preload only uncached messages and reduce rendering overhead

* Keep benchmark summaries without raw JSON results

* Use Ruby benchmark drivers and keep generated results out of the repo
2026-10-04 12:36:36 -04:00
Stanko K.R. 87ba7aa3cc Keep the message body list styles out of the mention menu
The composer editor carries the lexxy-content class, so the !important
list rules for message bodies hit the menu's ul and li too, leaving the
items cramped and pushed in from the left.
2026-09-26 11:16:03 +02:00
Stanko K.R. 5c5821a395 Keep an unsent message as a draft while switching rooms
The composer stores what's being written in localStorage per room and
restores it when the room is opened again. Sending clears it.
2026-09-26 10:38:13 +02:00
Stanko K.R. 1694accbf8 Let tables through the message sanitizers
Lexxy imports pasted and Markdown tables, and the tag sanitizer dropped
them with everything in them on render.
2026-09-26 10:04:38 +02:00
Stanko K.R. 6acad0549d Rebuild every attachment before editing a message
The editor keeps an attachment's content as it finds it, so a hand-written
embed with a url but no href reached the editor with its markup unvalidated,
and a mention saved under Trix's editor carried the generic octet-stream
content type the editor doesn't permit and was dropped on save. Rebuilding
each attachment from its attachable renders the hardened preview partial
and restores the mention content type.
2026-09-26 09:51:05 +02:00