feat: support ticketing chat screens - #11
Conversation
…rt + SSE reconnect - [id].tsx: isTicketActive now excludes only 'closed' so a user can reply to a resolved ticket (reopens to 'in progress' per spec); update closed copy. - [id].tsx: manual SSE reconnect with refetch-on-reconnect (pollingInterval:0 disables the library's own reconnect); gate cardno-bearing logs behind __DEV__; optimistic temp message uses createdAt (was created_at). - index.tsx: sort ticket list by createdAt (ids are random hex, b.id-a.id was NaN). - collectDiagnostics: withTimeout guard on Network.getNetworkStateAsync and Device.getUptimeAsync so a hung native call can't block ticket creation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ShadowButton never passed interactive={true} to the underlying ShadowBox,
so it always rendered as a plain View (onPress silently dropped) instead of
a TouchableOpacity — the ticket list row was completely unresponsive to taps.
No other screen uses ShadowButton, so this has no other blast radius.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ate guidance, SSE watchdog
- create.tsx: set expectations up front and on success that this isn't an
instant-reply channel — admin can take up to 3 business days.
- [id].tsx: show the ticket's original description as the first entry in the
thread (it was never rendered at all before), add a status badge and a
tap-to-copy ticket ID in the header, and — while status is 'resolved' — an
inline banner explaining support considers it fixed and prompting the user
to close it (or reply to reopen). Renamed the self-close action from
'Resolve' to 'Close Ticket' throughout to stop conflating the user's own
close action with the admin's separate 'resolved' status.
- [id].tsx: added a liveness watchdog paired with the backend's {type:'ping'}
heartbeat — react-native-sse fires no event at all on a graceful close, so
'no ping in 40s' is now used to force a reconnect proactively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-time banner
- [id].tsx: handle the backend's status_update SSE frame so status
badge/banner/input state update instantly when admin (or the user, from
another device) changes status without also sending a message — this was
the actual cause of 'have to reload to see the status update.'
- index.tsx: refetch on screen focus (query defaults don't refetch on focus)
so returning to the ticket list after visiting a ticket shows current data.
- [id].tsx: added a persistent banner ('this isn't a live chat, up to 3
business days') fixed above the thread, visible every time the ticket is
opened — not just a one-time alert at creation that's easy to forget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nse-time copy - [id].tsx: the 'resolved' banner was appended at the end of the scrollable thread, so a status-only change (no new message) never triggered the existing auto-scroll-on-new-message effect, leaving it invisible until the user scrolled down manually. Moved it out of the FlashList entirely into a fixed position above the input, with clearer icon+headline+subtext styling. - Dropped the 'this isn't a live chat' framing everywhere (create screen, submit confirmation, in-ticket banner) in favor of a plain response-time statement, matching how Zendesk/Freshdesk/Intercom phrase these — a factual SLA line, not a comparison to chat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without it the last chat bubble sat flush against the banner's top edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- support/index.tsx: fixed the scroll-to-top effect snapping the list back to the top on every successful pagination load (only scroll on a genuine refetch/reset now, tracked via previous page count). - support/index.tsx & [id].tsx: fixed handleAPICall calls that passed the reject-on-error function as the finallyCallback (6th arg) instead of errorCallback (7th) — real fetch errors were being replaced by a generic hardcoded message. - support/index.tsx & [id].tsx: skip the very first useFocusEffect firing (which coincides with the query's own fetch-on-mount) to stop doubling the initial request; also fixed [id].tsx's useCallback to list in its dependency array. - support/create.tsx: restored the minimum description length check (was silently dropped vs the old form), added a discard-changes confirmation when closing with unsaved input, and fixed a CustomAlert call that passed the message as the title with no body. - support/[id].tsx: resolveTicketMutation now passes allowToast=false like its sibling sendMessageMutation, so a failed close doesn't show both a generic toast and a CustomAlert for the same failure. - support/[id].tsx: optimistic and SSE-confirmed messages now carry a stable _key that survives temp->real reconciliation, so FlashList updates the existing cell instead of unmounting/remounting it (was causing a flicker right as a sent message confirmed); keyExtractor uses it. Documented the FIFO delivery assumption the temp-message matching relies on. - components/ShadowBox.tsx: fixed the root cause of a prior ShadowButton bug — ShadowBox now defaults to true for variant="button" when the caller doesn't explicitly pass it, instead of always defaulting to false. Verified no existing ShadowBox caller uses variant="button" without ShadowButton, so this changes nothing for existing usages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…all cleanups
/simplify pass on the ticketing feature:
- New src/hooks/useRefetchOnFocus.ts: extracts the "skip the first
useFocusEffect fire, refetch on subsequent focus" pattern that was
copy-pasted identically (with a near-verbatim explanatory comment) in both
support/index.tsx and support/[id].tsx. Independently flagged by all four
review angles.
- useTicketStream.ts no longer takes a flatListRef or calls scrollToEnd
itself — [id].tsx's own effect watching `ticket.messages.length` already
covers every case that actually adds a message (initial load, a real
incoming SSE message, and the optimistic send), so the hook's copy was
redundant and also fired unnecessarily on temp-message reconciliation
(array length unchanged, so nothing new to scroll to).
- useTicketStream.ts: merged the duplicate-id check and the temp-message-match
scan (previously two separate array passes: `.some()` then `.findIndex()`)
into one loop.
- ShadowButton no longer passes interactive={true} explicitly — dead weight
now that ShadowBox itself defaults variant="button" to interactive.
- collectDiagnostics.ts: fixed a misplaced JSDoc block that was documenting
collectDiagnostics() but sat above the unrelated withTimeout() instead.
- resolveBaseUrl.ts: softened a comment that overclaimed itself as the
"single source of truth" — a pre-existing unrelated file (updateManager.tsx)
still reads BASE_URL directly and isn't routed through this, which the
comment now says explicitly rather than implying full coverage.
Skipped: generalizing useTicketStream into a domain-agnostic SSE hook (it's
still ticket-specific — the URL, cache key shape, and message semantics are
baked in). A real generalization is a bigger abstraction exercise than this
cleanup pass warrants and risks regressing a hook that was just extracted
and verified; worth doing when a second live-updating screen actually needs
it. Also skipped a no-op `case STATUS_CLOSED` in ticketStatus.ts that
happens to match the default — removing it saves nothing and loses explicit
documentation that closed tickets are intentionally styled that way.
Verified: npx tsc --noEmit clean (0 errors), grepped for dangling references
to everything removed/renamed — none found.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @vendz's task in 9m 33s —— View job Claude Code Review — Support TicketingReviewed all 18 changed files. Overall this is well-engineered — the SSE reconciliation, watchdog reconnect, and defensive diagnostics collector are notably solid. Findings posted inline; summary below. 🔴 Correctness
🟡 UX / conventions
🟢 Nits (non-blocking)
👍 Nice work
|
| } = useQuery<any>({ | ||
| queryKey: ['ticket', id, user.cardno], | ||
| queryFn: fetchTicketDetails, | ||
| }); |
There was a problem hiding this comment.
Re-opening a ticket can show a stale thread (missing replies). The global QueryClient sets refetchOnMount: false (src/app/_layout.tsx:27). Re-opening a ticket remounts this screen, so:
- the query does not fetch on mount (cache exists →
refetchOnMount:false), useTicketStreamskipsrefetch()on the first SSEopen(useTicketStream.ts:50),useRefetchOnFocusskips the first focus (useRefetchOnFocus.ts:17),
…and there's no pull-to-refresh here. Net result: any admin reply / status change that arrived while the ticket was closed won't appear until the next live SSE event. Both hooks' comments assume "the query's own fetch-on-mount" runs — but refetchOnMount:false disables exactly that for cached remounts.
Simplest targeted fix — force this query to refetch on mount so both first-open/first-focus skips stay valid:
| } = useQuery<any>({ | |
| queryKey: ['ticket', id, user.cardno], | |
| queryFn: fetchTicketDetails, | |
| }); | |
| } = useQuery<any>({ | |
| queryKey: ['ticket', id, user.cardno], | |
| queryFn: fetchTicketDetails, | |
| // Global default is refetchOnMount:false, but re-opening a ticket remounts | |
| // this screen; without this the cached thread renders stale on re-open | |
| // (missing replies/status changes that arrived while it was closed), and | |
| // neither the first SSE 'open' nor the first screen focus refetches. | |
| refetchOnMount: 'always', | |
| }); |
The same reasoning applies to the tickets list query in support/index.tsx (revisiting Support after leaving it also remounts) — consider refetchOnMount: 'always' there too.
| ) : ( | ||
| <CustomEmptyMessage | ||
| message={'Yay! No tickets found'} | ||
| imageClassName="h-[200px] w-[200px]" | ||
| /> | ||
| )} |
There was a problem hiding this comment.
Empty state flashes during the initial load. On first load isLoading is true and data is undefined, so sortedTickets is [] and FlashList renders this ListEmptyComponent. Since it only branches on isError, the user briefly sees "Yay! No tickets found" (plus the footer spinner) before tickets arrive. Gate the empty message on !isLoading:
| ) : ( | |
| <CustomEmptyMessage | |
| message={'Yay! No tickets found'} | |
| imageClassName="h-[200px] w-[200px]" | |
| /> | |
| )} | |
| ) : isLoading ? ( | |
| <ActivityIndicator /> | |
| ) : ( | |
| <CustomEmptyMessage | |
| message={'Yay! No tickets found'} | |
| imageClassName="h-[200px] w-[200px]" | |
| /> | |
| )} |
(ActivityIndicator is already imported.)
| <KeyboardAvoidingView | ||
| behavior="padding" | ||
| // keyboardVerticalOffset={Platform.OS == 'android' ? 120 : 0} | ||
| className="flex-1"> |
There was a problem hiding this comment.
Chat input + Android keyboard. Two things here:
- The commented-out
keyboardVerticalOffsetline is dead code — worth removing (its presence hints Android keyboard behavior was already fiddly). - Bare
KeyboardAvoidingViewwithbehavior="padding"is unreliable on Android (padding behavior is effectively an iOS pattern). This project already depends onreact-native-keyboard-controller(used increate.tsxand the old support screen); for a chat screen with a pinned input, itsKeyboardAvoidingView/KeyboardStickyViewis the more robust choice and keeps things consistent with the rest of the codebase.
If you keep RN's KeyboardAvoidingView, at minimum make the behavior platform-aware (behavior={Platform.OS === 'ios' ? 'padding' : 'height'}, importing Platform) and pass a keyboardVerticalOffset equal to the header height, since the SafeAreaView + PageHeader above the list will otherwise offset the calculation. Please verify on a physical Android device.
- ticket detail + list queries: refetchOnMount:'always' so re-opening a remounted screen doesn't show a stale thread/list (global default is refetchOnMount:false, and both useTicketStream and useRefetchOnFocus skip their first pass) - list empty state: gate 'No tickets found' on !isLoading so it doesn't flash during the initial load - chat input: use react-native-keyboard-controller's KeyboardAvoidingView (RN's behavior='padding' is unreliable on Android) and drop the dead commented keyboardVerticalOffset - clear the scroll-to-end timeout on unmount/re-run Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client-side image compression and inline video playback for ticket media attachments. Registers the expo-video config plugin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New util (types, limits mirroring the backend, presign call, S3 PUT, and the serve-URL builder that avoids the doubled /api/v1 prefix) plus a reusable hook that stages picked media, compresses images (resize longest edge ~1600px, JPEG ~0.7), validates type/size/duration/count client-side, then presigns and PUTs each file to S3 with per-file status, overall progress, and cancel support. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- MediaViewer: full-screen modal with pinch/pan/double-tap zoom for images and an expo-video player for video. - AttachmentPreviewStrip: staged (pre-upload) thumbnails with remove + per-file upload status, shared by create and chat composers. - TicketMessageAttachments: renders served attachments (tappable images, inline video players) with a placeholder for media expired after 60 days. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- create.tsx: replace the 6-service list with the 12 backend departments (exact labels/order); add photo/video pickers, a preview strip, and run the upload flow before creating the ticket with attachment keys. Discard-changes now also accounts for staged media. - [id].tsx: paperclip attach action + preview strip in the composer; send messages with images/video via the upload flow; render creation and per- message attachments (tappable images -> full-screen viewer, inline video), optimistic local media on send, and expired placeholders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…caps - Preserve optimistic `_localMedia` when the SSE frame confirms a message with no attachments, so just-sent media stays visible until the refetch backfills served attachments; renderer now prefers server attachments and falls back to local media. - Clear staged attachments only in the send mutation's onSuccess (not before it), so a failed POST keeps the composer media and its uploaded S3 keys for retry; restore the message text on error. - Cap videos per TICKET (existing + staged <= MAX_VIDEOS) by deriving the existing video count from the loaded ticket detail; pass it into the hook. - On partial-batch upload retry, skip files already uploaded and reuse their S3 keys instead of re-presigning/PUTting (which orphaned the first copies). - Surface a message when images beyond the remaining slots are dropped (Android often ignores selectionLimit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Require the 10-char description minimum only when no attachment is staged; enable submit + pass validation when the description is long enough OR at least one attachment is present (the backend accepts attachment-only tickets, matching the chat composer). Discard-changes behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the expo-video player into a VideoContent child rendered only on the video branch, so no native player is constructed on the image path, and drop the dead `if (!player) return` guard (useVideoPlayer never returns null). Source-change/autoplay behavior preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ottom sheet - putToS3: upload via expo-file-system native binary upload instead of fetch(file://).blob() — RN's fetch can't read a local file URI, so uploads silently failed before reaching S3 (would affect real devices too). - create screen: the screen is presentation:'modal' but CustomAlert renders at the app root, so its Modal couldn't present over the create modal on iOS, leaving the screen frozen after submit. Use RN Alert for in-modal notices + close-on-success with a Toast. - Attachment picker is now a proper bottom sheet (AttachmentActionSheet), reused by the create screen and the chat composer, replacing the inline buttons / CustomAlert action dialog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ttom sheet - The bottom sheet's option couldn't launch the picker: dismissing the sheet Modal and immediately presenting the image picker races on iOS (can't present while another modal is dismissing), so nothing opened. - Replace the two-option (Photo/Video) sheet with one system gallery picker showing images AND videos (mediaTypes: ['images','videos']); a single addMedia() routes each picked asset by type, compresses images, and enforces the per-type caps. Opens directly from the attach button (no intermediate modal), which also fixes the launch race. Removed AttachmentActionSheet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…images appear live Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…el locations Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
Claude finished @vendz's task in 3m 9s —— View job Claude Code Review — Support Ticketing (re-review on latest push)
Prior findings — all verified fixed
New code since last review (media attachments, 12 departments, video support)Went through
One very minor, non-blocking observation: in No new correctness/security/perf issues found. Nothing to post inline this round. Note:
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @vendz's task in 1m 19s —— View job Claude Code Review — Support Ticketing (re-review on latest push)
Only one new commit landed since the last review: This is a clean, small refactor:
One very minor, non-blocking nit: No new correctness/security/perf issues. All findings from the previous two review rounds remain fixed (verified again in the current diff). Nothing to post inline this round.
|

Summary
Key pieces
src/app/support/index.tsx,src/app/support/create.tsx,src/app/support/[id].tsx— list, create, and chat/detail screens.src/hooks/useTicketStream.ts— SSE hook with optimistic-message reconciliation, watchdog-based reconnect, status-update handling.src/hooks/useRefetchOnFocus.ts— shared focus-refetch hook (skips the double-fetch on initial mount).src/utils/collectDiagnostics.ts— diagnostics metadata collector sent at ticket creation.src/utils/resolveBaseUrl.ts,src/utils/ticketStatus.ts— small shared utilities extracted during review.src/components/ShadowBox.tsx— fixed a pre-existing bug whereShadowButtonwasn't reliably touchable (root-caused during this work).Testing
/code-reviewpass (xhigh effort, 15 verified findings) — all fixed. Followed by a/simplifycleanup pass — all findings applied or explicitly skipped as false positives.Test plan