Skip to content

feat(support): make the customer chat read and behave like a chat - #1297

Open
joshuakrueger-dfx wants to merge 8 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/customer-chat-dfx
Open

feat(support): make the customer chat read and behave like a chat#1297
joshuakrueger-dfx wants to merge 8 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/customer-chat-dfx

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Open ToDos — not closable from this branch

  • Dismiss the CodeQL alert as a false positive, or say the preview should go. CodeQL
    is red on this branch and stays red; the finding is analysed in full under The red CodeQL
    check
    below, including the measured 31-step data path. The Code Scanning API answers 403
    for the author and the branch protection is not readable either, so neither dismissing the
    alert nor checking whether it blocks the merge is possible from here. Needs a maintainer.
  • Decide when the app asks for notification permission — tracked in Tell the customer about a support reply instead of waiting to be checked #1298, the one gap
    from this round that needs a backend. Not part of this PR.

Not symptom-driven: no incident report. The customer support chat was read end to end after a request to make answering a ticket feel like a normal chat. What came out was not a design brief: the screen is already built as one — customer bubbles carry bg-[#24A1DE], Telegram's brand colour, hard-coded and outside the palette in tailwind.config.js. Every finding below is quoted from develop.

Scale: /support/chat is the only way a customer reads and answers a support ticket in the app, so every ticket goes through this screen. The findings are not state-dependent: the date separator breaks in any thread spanning a month boundary, the delivery tick sits on every support reply, and the author line is missing on every attachment. No production measurement of chat usage is available, and that is stated rather than glossed over.

Smaller fix considered: roughly fifteen lines — swap the colour class, drop one !file, add one condition on the tick. Deliberately not chosen, and the reason is not taste: this repository requires 100 % statement, branch, function and line coverage for every file a PR touches. The reply and reaction code below is unreachable and therefore cannot be covered at all. As long as it sits in the file, the coverage requirement cannot be met no matter how small the actual edit is. Removing it is not scope creep — it is the precondition.


What was wrong

1 — Replies and emoji reactions are dead code.

// chat.screen.tsx:79-89 on develop
function onChatBubbleClick(e?: React.MouseEvent<HTMLDivElement>, _message?: SupportMessage) {
  if (!e) { setClickedMessage(undefined); return; }
  e.stopPropagation();
  // TODO: Uncomment to enable replies & reactions (feature not yet available)
  // setMenuPosition({ top: e.clientY, left: e.clientX });
  // setClickedMessage(message);
}

clickedMessage is therefore never set, the condition on line 138 is always false, and ChatBubbleMenu never renders. Unreachable with it: the menu (612–660), emojiSet, both quote previews, the reaction chips.

There is no backend for any of it either. CreateSupportMessage in @dfx.swiss/core has no replyTo, the SDK never sends one, and replyTo/reactions appear nowhere in the api's support-issue subdomain (0 hits). handleEmojiClick in the SDK writes local state only, under its own // TODO (later): Update message on server side. Switching it on would promise something that disappears on reload.

2 — The date separator compares the day of the month. Line 118 used getDate(), so 6 July and 6 August count as the same day and no separator appears. index > 0 also meant the first message never got one.

3 — The delivery tick sat on other people's messages. The status block rendered for every bubble; support replies arrive without a status and fell into the "received" branch, so they carried a tick that means nothing.

4 — The author name was missing on attachments. hasHeader && !isUser && !file (line 468) excluded any message carrying a file. Who sent the document was not visible.

5 — Palette-foreign classes. Besides #24A1DE: text-black and text-gray-500. theme.colors is replaced in tailwind.config.js, not extended, so text-gray-500 produced no rule at all.

6 — The composer had no visible input field. Container and textarea both carried bg-dfxGray-300, without a border — no boundary, just placeholder text floating in grey. The send button was a bare icon with no disabled attribute: clickable on an empty field, only visually dimmed. outline-none sat on both container and field with no replacement, neither icon control had an aria-label, and both were about 40 px where 44 is the floor.

7 — Attachments could only come from the file dialog. No onPaste, no onDrop (0 hits) — while pasting a screenshot is the most common way a customer shows a problem. And an attachment without any text could not be sent at all, although the SDK explicitly allows it (if (!hasText && !hasFiles) return;). That only became visible once pasting made "image, no words" the normal case.

8 — An arriving message yanked the reader to the bottom. The jump fired on every change, including while the customer was scrolling up to re-read something, and it was always animated — so opening a long thread visibly raced past. prefers-reduced-motion was ignored.

What changed

Nothing new was added to the data model; the api is untouched.

  • Palette colours throughout, #24A1DE gone
  • Separator on the calendar day, above the first message too, labelled Today / Yesterday
  • Delivery status only on the customer's own messages; author name also above attachments
  • Reply UI, reaction chips, bubble menu and two further unreachable branches removed — the document branch of the lightbox (setShowPreview only ever runs for images) and a dead if (!hasFile) return
  • Composer: white pill on the grey bar, rounded top corners, filled 44 px send button with a real disabled, visible focus rings, aria-labels, readable contrast on the file chips, and clearance for the home indicator on edge-to-edge phones
  • Paste and drop for attachments, thumbnail preview before sending, rejected types explained
  • Auto-scroll only when the reader was at the bottom, with a New divider and a labelled jump button otherwise; instant on first open and whenever reduced motion is requested

Verification

Full gate on a 28-core machine with Node 20, on the exact SHA of this branch (compared with the local one): npm ci, npm run lint (empty output), npm run format:md:check, CI=true npm test -- --watchAll=false, npm run build:dev, npm run widget:dev — all exit 0, 75 suites / 884 tests.

Coverage, measured per file as CONTRIBUTING asks:

File Stmts Branch Funcs Lines
src/screens/chat.screen.tsx 100 100 100 100
src/util/support-helpers.ts 100 100 100 100

support-helpers.ts includes two pre-existing functions that had no test at all (typeLabel, reasonLabel); both branches of each are covered now.

Counter-checks. Every new test fails without its fix. Mutations run individually, each with the failing test named:

Mutation Result
isSameCalendarDay reduced to getDate() — the original bug 3 failed
first date separator suppressed 5 failed
customer bubble given the support colour 2 failed
delivery status condition isUser!isUser 1 failed
!file restored on the author line 1 failed
rounded-t-lg removed from the bar 1 failed
scroll auto/smooth swapped 1 failed
reduced-motion branch removed 1 failed
send guard (text || files)(text && files) 1 failed
paste type filter removed 1 failed
"was at the bottom" condition negated 1 failed
revokeObjectURL dropped from cleanup 1 failed

Two guards worth naming

(inputValue?.length ?? 0) <= 4000 and Array.from(e.dataTransfer.files ?? []) are the two new
?? defaults in this diff. Neither masks a backend or parse error: inputValue is
useState<string>() without an initial value, and dataTransfer.files is absent in synthetic
events and incomplete polyfills — without the fallback Array.from throws and the drop handler
dies. Both branches are covered by tests rather than argued away.

Why only the customer screen

The same messages are also rendered for staff, by SupportMessageList in
src/components/support/info-panel.tsx, which three screens share —
support-dashboard-issue, realunit-support-issue and realunit-compliance-user. That
component is not touched here, and the difference is deliberate rather than an oversight:
each side highlights its own messages, so the customer sees their bubbles in dfxBlue-800
while staff see the customer's in grey. Aligning the two would mean showing staff their own
replies as the customer's colour.

It also keeps this branch clear of #1291, which is editing exactly that file for the clerk-side
reply suggestions.

The parallel api work was checked for collisions too: #4761 adds a reply-suggestion column to
support_message with its own migration. That is additive, so nothing here or in
DFXswiss/packages#210 reads a field that changes shape.

Handbook

The customer chat had no Playwright spec and no baseline. e2e/support-chat.spec.ts is new and covers four visual variants, generated on macOS against a local api: thread with day separation, attachment with author, sending and failed states, and relative Today/Yesterday labels. Entry added to scripts/handbook/metadata.json.

The relative-day baseline derives its dates from the current day so the labels stay Today and Yesterday; the times are pinned so the timestamp does not move between runs. It can flip within roughly one second around midnight, if the calendar day changes between building the fixture and rendering. Pinning page.clock would close that and is deliberately not done here.

Not verified

  • No real network failure was played through in a browser; the failed-message state comes from fixture data.
  • The safe-area padding is set but untested on an actual edge-to-edge phone.
  • Paste and drag were exercised through synthetic events, not a real clipboard.
  • The support agent's side of a ticket was never walked; this PR only touches the customer screen.

The red CodeQL check, and why it stays red

CodeQL reports one high-severity alert: DOM text reinterpreted as HTML, on the src of the
new attachment preview. Every other check passes, and the full gate is green with 886 tests at
100 % coverage.

Because the Code Scanning API answers 403 for the author, the data path was not guessable from
the annotation. It was therefore measured: CodeQL 2.26.2 with javascript-queries 2.4.2 — the
same versions the workflow uses — run locally against this branch. The path has 31 steps:

e.target.files (399)  →  Array.from  →  addFiles (379)  →  filter (382)
  →  setSelectedFiles (394)  →  selectedFiles (350)  →  URL.createObjectURL(file) (357)
  →  previewUrls (353)  →  asBlobPreviewUrl (59-60)  →  previewSrc  →  src (500)

Three things follow from it.

The source is the plain file dialoge.target.files — not the clipboard or a drop. Any
reasoning that starts elsewhere is wrong; two earlier attempts on this branch did exactly that.

The guard does not count. asBlobPreviewUrl appears inside the path, at lines 59–60: the
query walks straight through it. It hands the value back unchanged, so as far as the analysis is
concerned the same tainted string goes in and comes out. A third variation of the same idea would
be equally ineffective.

The sink cannot interpret HTML. The value is whatever URL.createObjectURL returns, and that
is a blob: URL under all inputs — never javascript:, never data:. An <img src="blob:…">
renders an image. The query does not model createObjectURL as a sanitizer, which is a known
pattern for file-upload previews.

Two changes were made along the way and are kept, because each is right on its own terms:

  1. The image check no longer looks at the filename, only at the MIME type — a user-controlled name
    has no business deciding whether an img src is built. Accept/reject still uses the extension,
    which gates no src.
  2. asBlobPreviewUrl keeps the sink honest for a human reader, even though the query ignores it.

What a maintainer needs to decide: dismiss the alert as a false positive, or say that the
preview should go and with it the sink. Both are out of reach from this branch — 403 on the
Code Scanning API, and the branch protection is not readable either. The finding is documented
here rather than worked around a third time.

Deviation from the guidelines

pb-[max(1rem,env(safe-area-inset-bottom))] introduces a pattern this repository does not have. A search for safe-area returns nothing, so there was no precedent to follow. On every non-notched device it resolves to the same value as pb-4. Flagged here rather than left for review to find; happy to drop it if the convention is unwanted.

Follow-up, not in this PR

#1298 — telling the customer about a reply at all. Four gaps against a real chat are closed
here; the fifth is not, because it needs a service worker in this repository and a subscription
entity, a migration, an endpoint and VAPID configuration in the api. It also needs a product
decision — when to ask for permission, and how much of a reply may appear on a lock screen. The
issue carries the evidence: zero hits for web-push, vapid and pushSubscription across the
api, and no service worker here.

Tapping a failed message to send it again is built and works locally, but it needs retryMessage from @dfx.swiss/react — see DFXswiss/packages#210. npm ci resolves the published 1.7.x, where the build fails on the missing type, so the wiring is deliberately absent here. Per CONTRIBUTING: add additively, release, then consume.

…eply UI

Replies and emoji reactions were fully built but switched off by a commented-out
line, so the menu could never open. Nothing stored them either: the SDK never
sends replyTo and the API knows neither field. The unreachable code also made the
100 % coverage this repository requires impossible to meet, so it goes.

- customer bubble in dfxBlue-800 instead of Telegram's #24A1DE, timestamps and
  the author name in palette colours
- date separator on the calendar day, above the first message, with Today and
  Yesterday
- delivery status only on the customer's own messages
- author name also above an attachment
- remove the reply preview, reaction chips, bubble menu, the unreachable document
  lightbox and a dead early return
The three committed baselines all use fixed July 2024 fixtures, so they only
ever show the absolute-date fallback. The relative labels the change introduces
appeared in no screenshot, which the handbook is built from.

Dates are derived from the current day so the labels stay Today and Yesterday;
the times are pinned so the timestamp in the bubble does not move between runs.
The input field carried the same background as its container and had no border,
so it had no visible boundary at all. The send button was a bare icon: no
surface, no focus ring, and no disabled attribute, so it stayed clickable on an
empty field while only looking dimmed.

- input as a white pill on the grey bar, with a top separator
- send button as a filled 44px circle, disabled when there is nothing to send
- visible focus rings replacing the bare outline-none
- aria-labels on both icon controls, 44px hit areas
- file chips to dfxBlue-800 on dfxGray-400 for readable contrast
- round the top corners so the bar reads as a panel, not a cut-off block
- keep the bar clear of the home indicator on edge-to-edge phones
- jump to the newest message without animation on first open, and whenever
  the visitor asked for reduced motion
- cover the keyboard path of the send guard, which the disabled button made
  unreachable by click but not by Enter
Two gaps against a real chat, both in the customer's way:

- a screenshot could only be attached through the file dialog; paste and drop
  now work, image picks show a thumbnail before they go out, and rejected
  types say why
- an arriving message yanked the reader to the bottom mid-sentence; the jump
  now only happens when they were at the bottom anyway, with a New divider and
  a labelled jump button otherwise

Sending an attachment without any text was refused by the screen although the
SDK allows it, which only surfaced once pasting made that the normal case.

A failed message now reads as an error instead of being dimmed away. Tapping
it to send again needs a released SDK and follows separately.
Comment thread src/screens/chat.screen.tsx Fixed
CodeQL (js/xss-through-dom) traces the preview src back to clipboard and drop
File objects and rates it high severity. The URL is always a blob: from
createObjectURL, so nothing was exploitable, but the flow is real and the guard
belongs where the value is used, not where it is built.

Also drops the filename from the image check: a user-controlled name has no
business deciding whether an img src gets built. Accept/reject keeps using the
extension, which gates no src.
>
{previewSrc ? (
<img
src={previewSrc}
@joshuakrueger-dfx
joshuakrueger-dfx marked this pull request as ready for review August 9, 2026 13:05
…d sync

The chat had five ways to fail in front of the customer and reported none of
them. The worst was silent: the context sets isError when a sync request fails,
the screen never read it, so after a network hiccup no further message arrived
and nothing said so.

- report a failed send, a failed load, a stalled sync and a rejected file type
  through reportClientError, which this repository gained last week; the 4000
  character limit stays unreported because it is input validation, not a fault
- show a quiet line while the sync is failing, gone as soon as one succeeds
- leave the timestamp blank instead of printing Invalid Date

formatSwissTime itself is untouched: it has many callers and changing it there
belongs in its own pull request.
The retry itself lives in the context (DFXswiss/packages#210). Published
@dfx.swiss/react 1.7.x does not carry it yet, so the screen reads the function
optionally through a type that names exactly that one property — no any, no
wide cast.

Without it the failed bubble stays as it is: visible as an error, but no tap
target and no offer to retry, because a promise that is not kept is worse than
none. With it the bubble becomes a button and a second tap while the retry is
in flight does nothing.
@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@mara-steiner could you take a look? The customer support chat: DFX colours instead of Telegram's, calendar-day separators with Today/Yesterday, delivery status only on the customer's own messages, pasting and dropping screenshots with a preview, keeping the reader's place with a New divider, and reporting the errors the customer is shown.

Two things worth your attention, both written up in the description: the red CodeQL check is a measured false positive on the attachment preview — the data path was traced locally with the same CodeQL version the workflow uses, and the sink cannot interpret HTML — and the safe-area padding introduces a pattern this repository does not have yet, declared as a deviation.

The retry on a failed message needs DFXswiss/packages#210 released first; it is wired optionally so the build passes against the published 1.7.x today.

@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@marassteiner Review request — formal GitHub reviewer assignment is not available for this repo (collaborator permission). Please review when you can.

@marassteiner

Copy link
Copy Markdown
Collaborator

Sorry — I've reached my hourly job limit and can't take this on right now. I'll pick it up automatically once the limit resets.

@marassteiner

Copy link
Copy Markdown
Collaborator

Job dfxswiss__services__1297__pr-review: Die Arbeitskopie konnte nicht hergestellt werden.

1 similar comment
@marassteiner

Copy link
Copy Markdown
Collaborator

Job dfxswiss__services__1297__pr-review: Die Arbeitskopie konnte nicht hergestellt werden.

@marassteiner

Copy link
Copy Markdown
Collaborator

EN: Working on this now — job dfxswiss__services__1297__pr-review (attempt 3).
DE: Ich arbeite jetzt daran — Auftrag dfxswiss__services__1297__pr-review (Versuch 3).

@marassteiner marassteiner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EN: Approve — no merge-blockers; follow-ups are filed as issues, and the red CodeQL alert is a documented false positive that a maintainer should dismiss.
DE: Approve — keine Merge-Blocker; Nacharbeiten liegen als Issues vor, der rote CodeQL-Alert ist ein dokumentierter False Positive und sollte von einem Maintainer verworfen werden.

Details

Recommendation

Approve. Nothing in this review is a merge-blocker. Follow-up findings are filed as issues and linked below. If a human treats the red CodeQL check as a required status, that is a maintainer dismissal (or an explicit product decision to drop the preview), not an author code change I am blocking on.

What was reviewed

PR #1297 (7701627c vs merge-base 6311c3a8): customer chat colours, calendar-day separators, delivery ticks, attachment author line, composer, paste/drop, scroll behaviour, dead reply/reaction UI removed.

Two independent full-diff reviews (conformity and logic) plus a local run.

Merge-blockers

None.

Claimed blocker Decision
100 % coverage on touched src/ files Rejected. Local measurement: chat.screen.tsx and support-helpers.ts are 100 % statements / branches / functions / lines (258/258 branches).
Optional retryMessage cast (chat.screen.tsx:653) Not a CONTRIBUTING violation. The published SDK is not called via fetch / useApi().call. The UI is offered only when typeof retryMessage === 'function' (:669). On @dfx.swiss/react ^1.7.0 that method is absent, so customers see no no-op retry.
Handbook missing every extra state Not a blocker. Four baselines plus scripts/handbook/metadata.json cover the main visual change. Extra states are #1371.
Red CodeQL check Not treated as a functional defect. See below. Uncertainty left to a human if that check is required to merge.

Declared deviations — accepted

  • pb-[max(1rem,env(safe-area-inset-bottom))] — no repo precedent; on non-notched devices it equals pb-4. Fine to keep.
  • CodeQL / preview img src — source is FileURL.createObjectURLasBlobPreviewUrl (chat.screen.tsx:60) which only returns blob: URLs. Analyze (javascript-typescript) is green. The failing check named CodeQL is the code-scanning decoration for that false positive (annotation on :500, later :546). A maintainer should dismiss it; the author cannot (403 on the scanning API).

Follow-up findings (issues)

None of these hold the merge:

  1. Mixed accepted/rejected attachment batch blocks send — chat.screen.tsx:423#1369
  2. Switching tickets can leave the thread at the previous scroll position — chat.screen.tsx:134, App.tsx:328#1370
  3. Handbook missing sync-error / unread-jump / composer-preview baselines — e2e/support-chat.spec.ts#1371
  4. Hardcoded English Download failed / Unknown error (pre-existing) — chat.screen.tsx:302, :770#1372
  5. Shift+Enter bypasses the 4000-character limit (pre-existing) — chat.screen.tsx:457#1373
  6. Rejected MIME accepted when the filename extension matches — chat.screen.tsx:44#1374
  7. Jump-to-bottom clears unread markers before the scroll finishes — chat.screen.tsx:187#1375
  8. No client-side attachment size/count limit (pre-existing picker; now also paste/drop) — chat.screen.tsx:420#1376
  9. Composer cleared before the SDK has accepted the send (pre-existing SDK promise) — chat.screen.tsx:411#1377
  10. Remaining any in the click handler (pre-existing) and new tests — chat.screen.tsx:761#1378
  11. SDK never revokes blob URLs for loaded/sent attachments (pre-existing) — #1379

Already tracked, not in this PR: notifications (#1298), SDK retryMessage (DFXswiss/packages#210).

Local run

Step Result
Node 20.19.5 (isolated; host default is 24)
npm ci exit 0
npm run lint exit 0, empty output
CI=true npm test -- --watchAll=false 75 suites / 894 tests passed
Coverage (touched files) 100 / 100 / 100 / 100
npm run start:dev CRA reported “already running on port 3001” because OrbStack holds :3000 on this host — environmental, not this diff
PORT=54902 BROWSER=none HOST=127.0.0.1 npm start process listens on 127.0.0.1:54902; webpack compiled (“No issues found.”); GET /200, title DFX.swiss | Buy & Sell directly into your wallet; GET /static/js/bundle.js200

CI on the PR: Build and test, handbook, review bot, and CodeQL Advanced (Analyze) are green. Only the code-scanning CodeQL decoration is red (see above).

Not merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants