From 59d869d1edcbc9b748e3521f7d12ac7f592b6d04 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:04:48 +0530 Subject: [PATCH 01/22] feat: Ability to resize table column width (#14611) --- .../shared/helpers/MessageFormatter.js | 9 +- .../helpers/specs/MessageFormatter.spec.js | 10 +++ lib/custom_markdown_renderer.rb | 82 ++++++++++++++++++- package.json | 2 +- pnpm-lock.yaml | 10 +-- spec/lib/custom_markdown_renderer_spec.rb | 53 ++++++++++++ 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js index eb8fecf01..85ce67bc6 100644 --- a/app/javascript/shared/helpers/MessageFormatter.js +++ b/app/javascript/shared/helpers/MessageFormatter.js @@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => { }); }; +// Help center article tables persist column widths as an internal +// `` comment before the table. It exists only for the +// editor's markdown round-trip and must never surface as text — markdown-it runs +// with `html: false`, which would otherwise escape it into a visible comment in +// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in. +const COLWIDTHS_MARKER_REGEX = /\r?\n?/g; + const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g; const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)'; const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g; @@ -75,7 +82,7 @@ class MessageFormatter { isAPrivateNote = false, linkify = true ) { - this.message = message || ''; + this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, ''); this.isAPrivateNote = isAPrivateNote; this.isATweet = isATweet; this.linkify = linkify; diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index 12b84085c..3350399eb 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -126,6 +126,16 @@ describe('#MessageFormatter', () => { }); }); + describe('help center table colwidth marker', () => { + it('strips the internal colwidths marker from rendered output', () => { + const message = + '\n| A | B |\n| --- | --- |\n| 1 | 2 |'; + const formatter = new MessageFormatter(message); + expect(formatter.formattedMessage).not.toContain('cw-colwidths'); + expect(formatter.plainText).not.toContain('cw-colwidths'); + }); + }); + describe('#sanitize', () => { it('sanitizes markup and removes all unnecessary elements', () => { const message = diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb index 665d4c80c..fa191f5ed 100644 --- a/lib/custom_markdown_renderer.rb +++ b/lib/custom_markdown_renderer.rb @@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer @embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) } end + # Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema + # so cells without an explicit colwidth render the same minimum here as in the editor. + TABLE_CELL_MIN_WIDTH_PX = 50 + COLWIDTHS_COMMENT = // + + # The article editor serializes column widths as a `` HTML + # comment immediately before each resized table. Capture it (emitting nothing) so the + # next `table` can size itself; any other raw HTML keeps its default rendering. + def html(node) + match = node.string_content.match(COLWIDTHS_COMMENT) + return super unless match + + @pending_colwidths = match[1].split(',').map(&:to_i) + end + def table(node) - out('
') - super + widths = @pending_colwidths + @pending_colwidths = nil + + if sized_widths?(widths) + out(table_wrapper_open(widths)) + out(inject_table_sizing(capture_html { super(node) }, widths)) + else + out('
') + super + end out('
') end @@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer private + def sized_widths?(widths) + widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? } + end + + def fully_sized?(widths) + widths.all? { |w| w.to_i.positive? } + end + + # Fully-sized tables hug their exact width so the card doesn't trail empty space; + # partial tables stay a plain full-width card so flexible columns can expand. + def table_wrapper_open(widths) + return '
' unless fully_sized?(widths) + + %(
) + end + + # Let the gem render the whole table, then splice a and sizing style + # into the opening tag. Delegating the row/cell/tbody/alignment markup to + # super keeps this working across commonmarker upgrades. + # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule. + def inject_table_sizing(html, widths) + opening = %(
\n#{colgroup_html(widths)}) + html.sub(/]*>\n?/, opening) + end + + # Capture everything `super` writes by swapping the renderer's output buffer. + def capture_html + original = @stream + @stream = StringIO.new(+'') + yield + @stream.string + ensure + @stream = original + end + + # Total table width: each column's saved width, or the cell min for unsized ones. + def total_width(widths) + widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX } + end + + # Fully sized → lock to the exact total (min-width too, so a narrow saved width + # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills + # the container (flexible columns) yet scrolls when the sized columns exceed it. + def table_sizing_style(widths) + total = total_width(widths) + return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths) + + "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;" + end + + def colgroup_html(widths) + cols = widths.map { |w| w.to_i.positive? ? %() : '' } + "#{cols.join}\n" + end + def extract_image_width(src) query = URI.parse(src).query raw = query && CGI.parse(query)['cw_image_width']&.first diff --git a/package.json b/package.json index d8527051d..41313c8b3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.17", + "@chatwoot/prosemirror-schema": "1.3.19", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4b61061c..68e667953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.17 - version: 1.3.17 + specifier: 1.3.19 + version: 1.3.19 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.17': - resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==} + '@chatwoot/prosemirror-schema@1.3.19': + resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.17': + '@chatwoot/prosemirror-schema@1.3.19': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb index 28c5e069c..6484f2e6c 100644 --- a/spec/lib/custom_markdown_renderer_spec.rb +++ b/spec/lib/custom_markdown_renderer_spec.rb @@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do end end + describe '#table' do + def render_table(markdown) + doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table]) + described_class.new.render(doc) + end + + let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" } + + it 'renders a table without column widths when no marker is present' do + output = render_table(plain_table) + expect(output).to include('
') + expect(output).not_to include('colgroup') + expect(output).not_to include('cw-colwidths') + end + + context 'when every column has a saved width' do + it 'lays the table out at the total width with a sized colgroup' do + output = render_table("\n#{plain_table}") + # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full. + expect(output).to include('
') + expect(output).to include('
') + expect(output).to include('') + end + end + + context 'when only some columns have a saved width' do + it 'fills the container so unsized columns stay flexible, floored at the sized total' do + output = render_table("\n#{plain_table}") + # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it. + expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;') + expect(output).to include('') + # No exact-width lock on the wrapper or table — the table must be free to expand. + expect(output).to include('
') + expect(output).to include('width: 400px !important;') + expect(output).to include('') + expect(output.scan('colgroup').length).to eq(2) + end + + it 'does not emit the marker comment into the rendered html' do + expect(render_table("\n#{plain_table}")).not_to include('cw-colwidths') + end + end + describe '#image' do it 'renders width in px with responsive cap and auto height' do markdown = '![Sample](https://example.com/image.jpg?cw_image_width=400px)' From 33f75505259362d1a2e23cdcbb70918ced58470c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 10 Jun 2026 08:52:34 +0400 Subject: [PATCH 02/22] fix(whatsapp): restrict OGG voice recording to WhatsApp Cloud inboxes (#14692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording and sending an audio message from a **Twilio WhatsApp** inbox failed silently — Twilio rejected the media with delivery error `63019` ("Media failed to download") and the voice note never reached the customer. This restores audio sending for Twilio WhatsApp (and 360dialog) inboxes. Closes Regression from #14606 ## How to reproduce 1. Open a conversation in a **Twilio WhatsApp** inbox. 2. Record a voice message in the reply box and send it. 3. Before this fix: the message fails to deliver and a `Webhooks::TwilioDeliveryStatusJob` is enqueued with `ErrorCode: 63019`, `ErrorMessage: "Media failed to download"`. 4. After this fix: the audio is recorded as MP3 and delivers normally. ## What changed PR #14606 added WhatsApp **Cloud** voice notes, which require OGG/Opus. It changed `audioRecordFormat` in `ReplyBox.vue` to return OGG for `isAWhatsAppChannel` — but that getter is also `true` for Twilio WhatsApp inboxes. The OGG handling (content-type normalization + the `voice: true` flag) lives only in `WhatsappCloudService`, so Twilio could not download/process the remuxed OGG file. This change scopes OGG to `isAWhatsAppCloudChannel`. Twilio WhatsApp, 360dialog, and Telegram fall back to MP3 exactly as they did before the PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .../dashboard/components/widgets/conversation/ReplyBox.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 17f5559f1..fc93ff2c7 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -375,10 +375,10 @@ export default { return `draft-${this.conversationIdByRoute}-${this.replyType}`; }, audioRecordFormat() { - if (this.isAWhatsAppChannel) { + if (this.isAWhatsAppCloudChannel) { return AUDIO_FORMATS.OGG; } - if (this.isATelegramChannel) { + if (this.isAWhatsAppChannel || this.isATelegramChannel) { return AUDIO_FORMATS.MP3; } if (this.isAPIInbox) { From cabe9bc7332e89656cdd1ec8e30f4134d6b993a9 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:56:17 +0530 Subject: [PATCH 03/22] fix: populate general settings form on hard reload (#14685) --- .../routes/dashboard/settings/account/Index.vue | 12 +++++++++++- .../settings/account/components/AccountId.vue | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 0502ebc1b..55c1e7f03 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -94,8 +94,18 @@ export default { return this.getAccount(this.accountId) || {}; }, }, + watch: { + 'currentAccount.id'(id) { + if (id) { + this.initializeAccount(); + } + }, + }, mounted() { - this.initializeAccount(); + // Account already in the store (navigated in): seed immediately. + if (this.currentAccount.id) { + this.initializeAccount(); + } }, methods: { async initializeAccount() { diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue index f02efcdc2..941d79fb1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue @@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue'; const { t } = useI18n(); const { currentAccount } = useAccount(); -const getAccountId = computed(() => currentAccount.value.id.toString()); +const getAccountId = computed(() => currentAccount.value?.id?.toString());