Inside the Drawer
The drawer is one component swapping between three views. ActivityLogContent holds a panel
integer and returns early for two of the three:
panel | Renders | Reached from |
|---|---|---|
0 | the list (an inline, memo'd ActivityLogList) | default, and Back from either panel |
1 | AddMessagePanel | the ✉ Create Message header icon |
2 | ActivitySettingsPanel | the ⚙ Settings header icon |
Both panels receive BackButton and CloseButton as pre-bound components. They are wrapped in
useCallback so typing in the settings form does not re-render the header on every keystroke.
If you are changing behaviour rather than looking something up, read how the activity log works first — the grouping and pinning live there.
The list
Each ActivityLogGroup renders as one row, styled by sourceType:
sourceType | Row style | Expansion shows |
|---|---|---|
CameraLease | moveRow | nested movement list, plus a per-entry Reset Camera button |
ActivityLog | chatRow | reason code and message text |
AIDetection | aiRowContent | the image carousel below |
High-priority rows get a red stripe and an icon; rows still inside their pin window also get a
Pinned badge and a data-pinned="true" attribute (which is what the tests key off).
Reset Camera to Position appears in a collapsed lease row's header — and only there —
when all of these hold: ENABLE_RESET, the row is not expanded, sourceType is CameraLease,
reasonCode is neither Focus Adjustment nor Brightness Adjustment, and both pan and
tilt are numbers. Clicking it calls onMoveCameraTo(entry) from cameraMovementStore and
closes the drawer.
The header also carries Expand All / Collapse All, and an AI checkbox that only renders when
the user has AI access and the current list actually contains AI groups. When filters are
hiding anything, a banner shows N of M items with a Clear Filters button that resets
settings to defaultActivityLogSettings — which is the empty object, so it clears every
checkbox rather than restoring the all-true defaults.
Adding a message
AddMessagePanel is a two-field form: a High Priority checkbox and a message capped at 150
characters, with a live N/150 characters counter. Confirm is enabled only when the message is
non-empty, and disabled again while submitting so a double-click cannot post twice.
The payload posted to POST /api/add-message is small:
{
cameraName: cameraSelected.cId,
message,
isHighPriority,
isSystem: false, // always — this branch only creates user messages
}
On success the panel clears the field, calls setPanel(0), and flips requested back to
false with loading to true, which is what makes ActivityLogContent re-fetch and show the
new row. On failure it fires an error through notificationStore. Cancel just returns to the
list.
Filtering and sorting
ActivitySettingsPanel writes to localStorage on every change (via
saveSettingsToLocalStorage, under the key activity-log-settings) and pushes the new object
up through setSettings. There is no Save button; there is no dirty state.
The settings object is one flat map of string → boolean | string, whose legal keys are
enumerated in ActivityLogSettingsStructure:
| Key | Kind | Meaning |
|---|---|---|
updated-on | string | ISO timestamp, stamped automatically on save |
sort-by | radio | recent (default) or oldest |
start-date / end-date | date | ISO strings; empty means that bound is off |
type-activitylog | boolean | show sourceType === 'ActivityLog' (labelled "Message") |
type-cameralease | boolean | show sourceType === 'CameraLease' ("Camera event") |
type-automated | boolean | show isSystem === true ("Automated Camera event") |
reason-<code> | boolean | one per REASON_CODES entry, lowercased |
reason-camera reset | boolean | hardcoded extra, not from REASON_CODES |
urgency | radio | all (default) or urgent (isHighPriority only) |
filterActivityLog applies date, type, urgency, and reason as four independent passes. Each
group of checkboxes is pass-through when nothing in it is checked — no type checked means
all types show, no reason checked means all reasons show. That is why "Clear All" does not empty
the list. Unrecognised reason codes fall into reason-other reason.
Every checkbox label carries a count in parentheses, computed by
countActivityLogSettings(activitylog) over the full unfiltered log — so the number tells you
what you would get, not what you currently have.
AI detection rows
AIAnomalyGroupContent renders a Swiper carousel of the snapshot images an AI group collected —
group.allEntries[].message holds the image URL for AI entries, not text.
Two loading guards, because a log can hold many of these:
- The carousel is not mounted until the row scrolls near the viewport. A one-shot
IntersectionObserverwithrootMargin: '200px'setsinView, then disconnects; before that the row shows a skeleton. - Each slide tracks its own load in a
Set<number>, showing a per-image skeleton untilonLoadfires.
Desktop gets arrow navigation, mobile and landscape get clickable dot pagination. A
FloatingExpandButton swaps the container class to a fullscreen one — the expansion is pure
CSS. Below the carousel, the timestamp of the current slide renders as 24-hour HH:mm:ss. An
empty image list renders No images available instead of the carousel.
Props
ActivityLogContent is the only one of these you mount yourself:
| Prop | Type | Notes |
|---|---|---|
cameraSelected | CameraLocationAndImageModel | null | required; cId is used for fetch and message posts |
onDrawerClose | () => void | required |
fetchedActivityLog | ActivityLogResponse[] | null | optional; skips the mount fetch when the parent already has data |
fetchedActivityLog only covers the first render — skipInitialFetch is a ref that clears
itself, so every later re-request (after posting a message, for instance) really does hit the
API. It has to, because only fetchActivityLog clears loading.
The two sub-panels take cameraSelected, the bound BackButton/CloseButton, and their own
state handles: AddMessagePanel gets setPanel, setRequested and setLoading;
ActivitySettingsPanel gets settings, setSettings, and the full unfiltered activitylog
for its counts. AIAnomalyGroupContent takes a single group.