feat(support): make the customer chat read and behave like a chat - #1297
feat(support): make the customer chat read and behave like a chat#1297joshuakrueger-dfx wants to merge 8 commits into
Conversation
…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.
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.
7657929 to
e6475e4
Compare
| > | ||
| {previewSrc ? ( | ||
| <img | ||
| src={previewSrc} |
…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.
|
@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. |
|
@marassteiner Review request — formal GitHub reviewer assignment is not available for this repo (collaborator permission). Please review when you can. |
|
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. |
|
Job dfxswiss__services__1297__pr-review: Die Arbeitskopie konnte nicht hergestellt werden. |
1 similar comment
|
Job dfxswiss__services__1297__pr-review: Die Arbeitskopie konnte nicht hergestellt werden. |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
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 equalspb-4. Fine to keep.- CodeQL / preview
img src— source isFile→URL.createObjectURL→asBlobPreviewUrl(chat.screen.tsx:60) which only returnsblob:URLs.Analyze (javascript-typescript)is green. The failing check namedCodeQLis the code-scanning decoration for that false positive (annotation on:500, later:546). A maintainer should dismiss it; the author cannot (403on the scanning API).
Follow-up findings (issues)
None of these hold the merge:
- Mixed accepted/rejected attachment batch blocks send —
chat.screen.tsx:423— #1369 - Switching tickets can leave the thread at the previous scroll position —
chat.screen.tsx:134,App.tsx:328— #1370 - Handbook missing sync-error / unread-jump / composer-preview baselines —
e2e/support-chat.spec.ts— #1371 - Hardcoded English
Download failed/Unknown error(pre-existing) —chat.screen.tsx:302,:770— #1372 - Shift+Enter bypasses the 4000-character limit (pre-existing) —
chat.screen.tsx:457— #1373 - Rejected MIME accepted when the filename extension matches —
chat.screen.tsx:44— #1374 - Jump-to-bottom clears unread markers before the scroll finishes —
chat.screen.tsx:187— #1375 - No client-side attachment size/count limit (pre-existing picker; now also paste/drop) —
chat.screen.tsx:420— #1376 - Composer cleared before the SDK has accepted the send (pre-existing SDK promise) —
chat.screen.tsx:411— #1377 - Remaining
anyin the click handler (pre-existing) and new tests —chat.screen.tsx:761— #1378 - 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.js → 200 |
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.
Open ToDos — not closable from this branch
CodeQLis 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
403for 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.
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 intailwind.config.js. Every finding below is quoted fromdevelop.Scale:
/support/chatis 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.
clickedMessageis therefore never set, the condition on line 138 is always false, andChatBubbleMenunever renders. Unreachable with it: the menu (612–660),emojiSet, both quote previews, the reaction chips.There is no backend for any of it either.
CreateSupportMessagein@dfx.swiss/corehas noreplyTo, the SDK never sends one, andreplyTo/reactionsappear nowhere in the api'ssupport-issuesubdomain (0 hits).handleEmojiClickin 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 > 0also 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-blackandtext-gray-500.theme.colorsis replaced intailwind.config.js, not extended, sotext-gray-500produced no rule at all.6 — The composer had no visible input field. Container and
textareaboth carriedbg-dfxGray-300, without a border — no boundary, just placeholder text floating in grey. The send button was a bare icon with nodisabledattribute: clickable on an empty field, only visually dimmed.outline-nonesat on both container and field with no replacement, neither icon control had anaria-label, and both were about 40 px where 44 is the floor.7 — Attachments could only come from the file dialog. No
onPaste, noonDrop(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-motionwas ignored.What changed
Nothing new was added to the data model; the api is untouched.
#24A1DEgonesetShowPreviewonly ever runs for images) and a deadif (!hasFile) returndisabled, visible focus rings,aria-labels, readable contrast on the file chips, and clearance for the home indicator on edge-to-edge phonesVerification
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:
src/screens/chat.screen.tsxsrc/util/support-helpers.tssupport-helpers.tsincludes 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:
isSameCalendarDayreduced togetDate()— the original bugisUser→!isUser!filerestored on the author linerounded-t-lgremoved from the barauto/smoothswapped(text || files)→(text && files)revokeObjectURLdropped from cleanupTwo guards worth naming
(inputValue?.length ?? 0) <= 4000andArray.from(e.dataTransfer.files ?? [])are the two new??defaults in this diff. Neither masks a backend or parse error:inputValueisuseState<string>()without an initial value, anddataTransfer.filesis absent in syntheticevents and incomplete polyfills — without the fallback
Array.fromthrows and the drop handlerdies. Both branches are covered by tests rather than argued away.
Why only the customer screen
The same messages are also rendered for staff, by
SupportMessageListinsrc/components/support/info-panel.tsx, which three screens share —support-dashboard-issue,realunit-support-issueandrealunit-compliance-user. Thatcomponent 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-800while 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_messagewith its own migration. That is additive, so nothing here or inDFXswiss/packages#210 reads a field that changes shape.
Handbook
The customer chat had no Playwright spec and no baseline.
e2e/support-chat.spec.tsis 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 toscripts/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.clockwould close that and is deliberately not done here.Not verified
The red CodeQL check, and why it stays red
CodeQLreports one high-severity alert: DOM text reinterpreted as HTML, on thesrcof thenew attachment preview. Every other check passes, and the full gate is green with 886 tests at
100 % coverage.
Because the Code Scanning API answers
403for the author, the data path was not guessable fromthe annotation. It was therefore measured: CodeQL 2.26.2 with
javascript-queries2.4.2 — thesame versions the workflow uses — run locally against this branch. The path has 31 steps:
Three things follow from it.
The source is the plain file dialog —
e.target.files— not the clipboard or a drop. Anyreasoning that starts elsewhere is wrong; two earlier attempts on this branch did exactly that.
The guard does not count.
asBlobPreviewUrlappears inside the path, at lines 59–60: thequery 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.createObjectURLreturns, and thatis a
blob:URL under all inputs — neverjavascript:, neverdata:. An<img src="blob:…">renders an image. The query does not model
createObjectURLas a sanitizer, which is a knownpattern for file-upload previews.
Two changes were made along the way and are kept, because each is right on its own terms:
has no business deciding whether an
img srcis built. Accept/reject still uses the extension,which gates no
src.asBlobPreviewUrlkeeps 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 —
403on theCode 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 forsafe-areareturns nothing, so there was no precedent to follow. On every non-notched device it resolves to the same value aspb-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,vapidandpushSubscriptionacross theapi, and no service worker here.
Tapping a failed message to send it again is built and works locally, but it needs
retryMessagefrom@dfx.swiss/react— see DFXswiss/packages#210.npm ciresolves 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.@dfx.swiss/reacthere (pinned at^1.7.0, which does not include 1.8.x) and wire up the retry. Not doable from this branch.