From dad8159ba0d89824fd4f9e4ad361f344dc841e4b Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 13 May 2026 21:35:06 +0200 Subject: [PATCH] Add Thunderbird-style selective quote on reply Signed-off-by: Kai Wagner --- app/controllers/drafts_controller.rb | 12 ++++++- .../controllers/reply_quote_controller.js | 35 +++++++++++++++++++ app/views/topics/_message.html.slim | 4 +-- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 app/javascript/controllers/reply_quote_controller.js diff --git a/app/controllers/drafts_controller.rb b/app/controllers/drafts_controller.rb index 170519f..ae59cec 100644 --- a/app/controllers/drafts_controller.rb +++ b/app/controllers/drafts_controller.rb @@ -42,7 +42,7 @@ def create identity: identity, sender_alias: sender, subject: build_default_subject(parent), - body: "" + body: build_quoted_body(parent, params[:selected_text]) ) rescue ActiveRecord::RecordNotUnique current_user.outgoing_drafts @@ -139,6 +139,16 @@ def build_default_subject(parent) "Re: #{base}" end + def build_quoted_body(parent, selected_text) + return "" if selected_text.blank? + + display = parent.sender_display_alias + date_str = parent.created_at.strftime("%a, %d %b %Y") + header = "On #{date_str}, #{display.name} <#{display.email}> wrote:" + quoted = selected_text.strip.each_line.map { |l| "> #{l.chomp}" }.join("\n") + "#{header}\n#{quoted}\n\n" + end + def active_settings_section :my_emails end diff --git a/app/javascript/controllers/reply_quote_controller.js b/app/javascript/controllers/reply_quote_controller.js new file mode 100644 index 0000000..4d4d3b9 --- /dev/null +++ b/app/javascript/controllers/reply_quote_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this._stored = "" + this._onMouseUp = () => { this._stored = this._selectionInBody() } + this.element.addEventListener("mouseup", this._onMouseUp) + } + + disconnect() { + this.element.removeEventListener("mouseup", this._onMouseUp) + } + + injectSelection(event) { + const text = this._stored || this._selectionInBody() + this._stored = "" + if (!text) return + + const input = document.createElement("input") + input.type = "hidden" + input.name = "selected_text" + input.value = text + event.target.appendChild(input) + } + + _selectionInBody() { + const sel = window.getSelection() + if (!sel || sel.isCollapsed) return "" + const range = sel.getRangeAt(0) + const body = this.element.querySelector(".message-body") + return (body && body.contains(range.commonAncestorContainer)) + ? sel.toString().trim() + : "" + } +} diff --git a/app/views/topics/_message.html.slim b/app/views/topics/_message.html.slim index caacabd..83197ac 100644 --- a/app/views/topics/_message.html.slim +++ b/app/views/topics/_message.html.slim @@ -3,7 +3,7 @@ - collapse_read = local_assigns.fetch(:collapse_read, true) - is_collapsed = (render_mode == :collapsed) || (render_mode == :shell && is_read && collapse_read) - message_classes = [("reply-message" if message.reply_to_id), ("message-branch-#{local_assigns[:branch_index]}" unless local_assigns[:branch_index].nil?), ("is-collapsed" if is_collapsed)].compact.join(" ") -.message-card id=message_dom_id(message) class=message_classes data-controller="message-collapse" data-message-collapse-collapsed-value=is_collapsed data-message-id=message.id data-read=(is_read ? "true" : "false") +.message-card id=message_dom_id(message) class=message_classes data-controller="message-collapse reply-quote" data-message-collapse-collapsed-value=is_collapsed data-message-id=message.id data-read=(is_read ? "true" : "false") - if (mid_anchor = message_id_anchor(message)) a.message-id-anchor id=mid_anchor aria-hidden="true" - if local_assigns[:is_first_unread] @@ -68,7 +68,7 @@ - elsif can_reply = button_to drafts_path, params: { reply_to_message_id: message.id }, method: :post, - form: { data: { turbo_frame: "draft-#{message.id}" } }, + form: { data: { turbo_frame: "draft-#{message.id}", action: "submit->reply-quote#injectSelection" } }, class: "message-action message-action-form", title: "Reply", "aria-label": "Reply" do i.fa-solid.fa-reply