Skip to content

feat: support ticketing chat screens - #11

Open
vendz wants to merge 26 commits into
mainfrom
feat/support-ticketing
Open

feat: support ticketing chat screens#11
vendz wants to merge 26 commits into
mainfrom
feat/support-ticketing

Conversation

@vendz

@vendz vendz commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

  • New in-app support ticketing flow: create ticket, list of the user's tickets (polling refresh), and a per-ticket chat screen with live SSE updates for new messages and status changes.
  • Ticket creation collects a rich diagnostics snapshot (device/app/config/runtime/session info + linked Sentry event) automatically, invisible to the user, to help admins debug the underlying issue.
  • Clear in-app messaging that updates are real-time but responses are not instant — sets expectation of up to 3 business days for a first response (copy modeled on common ticketing-system conventions, iterated on with product feedback).
  • Resolved tickets show a persistent, always-visible (not scroll-dependent) banner explaining the user needs to confirm/close it, or it auto-closes after a grace period; users can still reply while a ticket is resolved-but-not-yet-closed.

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 where ShadowButton wasn't reliably touchable (root-caused during this work).

Testing

  • Manual end-to-end pass on iOS Simulator against a local backend: create ticket, live chat both directions, live status updates without manual refresh, copy ticket ID, resolve/close flow, reconnect after simulated network drop.
  • Went through a full /code-review pass (xhigh effort, 15 verified findings) — all fixed. Followed by a /simplify cleanup pass — all findings applied or explicitly skipped as false positives.

Test plan

  • Manual pass on a physical device (simulator testing only so far) for push notification delivery on ticket updates
  • Verify SSE behaves correctly on real cellular network conditions (simulator testing doesn't exercise real-world connection drops)

vendz and others added 13 commits July 4, 2026 14:56
…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

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @vendz's task in 9m 33s —— View job


Claude Code Review — Support Ticketing

Reviewed 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

  • Re-opening a ticket can show a stale threadinline. Global refetchOnMount:false means a remounted detail screen doesn't fetch on mount, and both useTicketStream (first open) and useRefetchOnFocus (first focus) skip their refetch on that first pass — so admin replies/status changes that arrived while the ticket was closed don't show until the next live SSE event (no pull-to-refresh here either). Both hooks' comments assume a mount-fetch that this config disables. Fix suggested (refetchOnMount:'always'); same likely applies to the list query.

🟡 UX / conventions

  • Empty-state flashes during initial loadinline. ListEmptyComponent shows "Yay! No tickets found" while isLoading. Suggestion to gate on !isLoading.
  • Chat KeyboardAvoidingViewinline. behavior="padding" is unreliable on Android; project already uses react-native-keyboard-controller. Also dead commented keyboardVerticalOffset. Needs a physical-Android check.

🟢 Nits (non-blocking)

  • [id].tsx — the scroll setTimeout (:207) and the copied reset setTimeout (:157) aren't cleared on unmount/re-run. Harmless (?.-guarded) but leaky.
  • index.tsxShadowButton applies both variant padding (px-6 py-3) and the passed p-4; conflicting utilities (last wins in NativeWind, likely fine — just confirm intent).
  • index.tsxgetNextPageParam returns pages.length + 1 for any non-empty page → one extra empty request when the final page is partial. Common pattern; fine.
  • No deep-link route for /support/[id] in deeplinks.ts yet, so tapping a ticket-update push won't open the ticket. (Already flagged as a TODO in the test plan.)
  • collectDiagnostics gathers broad PII/system info (cardno, name, device, disk, network, locale, uptime, Sentry id, resolved base URL). Intentional per the PR description — just make sure the backend stores/scopes it appropriately.

👍 Nice work

  • Single-pass optimistic-message dedup + temp reconciliation with _key preservation (avoids FlashList flicker) and the documented FIFO reasoning.
  • Watchdog to detect graceful SSE closes (which emit no error).
  • collectDiagnostics — per-group guards + withTimeout, never throws, never blocks ticket creation.
  • ShadowButton touchability root-cause fix is correct; good extraction of resolveBaseUrl / ticketStatus / useRefetchOnFocus.

Note: node_modules wasn't installed in this environment, so I didn't run npm run lint/tsc. No test framework is configured (per CLAUDE.md). This is a static review.

Comment thread src/app/support/[id].tsx
Comment on lines +54 to +57
} = useQuery<any>({
queryKey: ['ticket', id, user.cardno],
queryFn: fetchTicketDetails,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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),
  • useTicketStream skips refetch() on the first SSE open (useTicketStream.ts:50),
  • useRefetchOnFocus skips 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:

Suggested change
} = 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.

Comment thread src/app/support/index.tsx
Comment on lines +158 to +163
) : (
<CustomEmptyMessage
message={'Yay! No tickets found'}
imageClassName="h-[200px] w-[200px]"
/>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
) : (
<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.)

Comment thread src/app/support/[id].tsx Outdated
Comment on lines +295 to +298
<KeyboardAvoidingView
behavior="padding"
// keyboardVerticalOffset={Platform.OS == 'android' ? 120 : 0}
className="flex-1">

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Chat input + Android keyboard. Two things here:

  1. The commented-out keyboardVerticalOffset line is dead code — worth removing (its presence hints Android keyboard behavior was already fiddly).
  2. Bare KeyboardAvoidingView with behavior="padding" is unreliable on Android (padding behavior is effectively an iOS pattern). This project already depends on react-native-keyboard-controller (used in create.tsx and the old support screen); for a chat screen with a pinned input, its KeyboardAvoidingView / KeyboardStickyView is 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>
@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Claude Code Review — Support Ticketing (re-review on latest push)

  • Read updated changed files
  • Verify prior review findings were addressed
  • Look for new bugs / logic / security / perf / convention issues
  • Post inline findings + summary

View job run

vendz and others added 11 commits July 6, 2026 17:27
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

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude finished @vendz's task in 3m 9s —— View job


Claude Code Review — Support Ticketing (re-review on latest push)

  • Read updated changed files / diff since last review
  • Verify prior review findings were addressed
  • Look for new bugs / logic / security / perf / convention issues
  • Post inline findings + summary

Prior findings — all verified fixed

  1. Stale thread on re-opensrc/app/support/[id].tsx:98 and src/app/support/index.tsx:61 now both set refetchOnMount: 'always', closing the gap left by the global refetchOnMount:false default + the intentional first-pass skips in useTicketStream/useRefetchOnFocus.
  2. Empty-state flashsupport/index.tsx:162 now gates on isLoading before showing "No tickets found".
  3. Chat keyboard handling[id].tsx now uses react-native-keyboard-controller's KeyboardAvoidingView (consistent with create.tsx), dead commented offset line removed.

New code since last review (media attachments, 12 departments, video support)

Went through useTicketAttachments.ts, ticketAttachments.ts, MediaViewer.tsx, AttachmentPreviewStrip.tsx, TicketMessageAttachments.tsx, and the updated [id].tsx/create.tsx composer flows. This is careful, well-commented work:

  • Upload flow correctly avoids re-uploading already-uploaded files on retry (partial-batch failure handling in useTicketAttachments.ts:224-299).
  • Native File.uploadAsync used instead of fetch(uri).blob() — correct root-cause fix for the documented RN file-URI upload failure.
  • Per-kind caps (5 images / 2 videos per ticket, size/duration checks) are enforced client-side with clear backend-mirrors-this framing; abort-on-unmount via AbortController is handled.
  • MediaViewer/TicketMessageAttachments correctly isolate useVideoPlayer per video instance and only mount a player when a video is actually shown.

One very minor, non-blocking observation: in [id].tsx, sendMessageMutation.onSuccess calls clear() immediately (dropping staged attachments) before the onSettled refetch resolves, so existingVideoCount (used to gate canAddVideo) is briefly stale right after sending a video — a user could tap "attach video" again during that short window before the server-confirmed count lands. Not a real risk since the backend re-validates the cap at presign time (per the comment in ticketAttachments.ts), so this just means an occasional harmless upload-rejected round trip rather than data loss. Not filing this as a blocking finding.

No new correctness/security/perf issues found. Nothing to post inline this round.

Note: bdf2bf2 (Navi Mumbai Airport travel location) is unrelated to this ticketing feature — appears to be a stray commit riding on this branch, not part of the support-ticketing scope, so it wasn't in scope for this review.

node_modules isn't installed in this environment, so npm run lint/tsc weren't run — static review only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude finished @vendz's task in 1m 19s —— View job


Claude Code Review — Support Ticketing (re-review on latest push)

  • Check what changed since last review (commit range)
  • Read updated/new files
  • Verify prior findings still hold
  • Look for new bugs / logic / security / perf / convention issues
  • Post inline findings + summary

Only one new commit landed since the last review: fd7ab94 "refactor(tickets): share upload helper + canAddMedia flag" (touches src/app/support/[id].tsx, src/app/support/create.tsx, src/hooks/useTicketAttachments.ts, src/utils/ticketAttachments.ts).

This is a clean, small refactor:

  • Extracts the duplicated try { await upload() } catch (UPLOAD_CANCELLED) ... logic from create.tsx/[id].tsx into a shared runUpload() helper (ticketAttachments.ts:196-208) — swallows a user-cancel (returns null), otherwise re-throws with a fallback message. Both call sites updated consistently and correctly.
  • Collapses canAddImage/canAddVideo into a single canAddMedia flag where the two callers only ever used the OR of both — reduces boilerplate in the disabled/label logic in both screens without changing behavior.

One very minor, non-blocking nit: runUpload in ticketAttachments.ts:207 hardcodes the string literal 'UPLOAD_CANCELLED' instead of importing the UPLOAD_CANCELLED constant exported from useTicketAttachments.ts:26. Looks intentional though — useTicketAttachments.ts already imports from ticketAttachments.ts, so importing back would be circular. Not filing as a blocking finding.

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.

node_modules isn't installed in this environment, so npm run lint/tsc weren't run — static review only.

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.

1 participant