Skip to content

fix(huddle): attach Pulse videos recorded from the composer - #495

Open
Dharp02 wants to merge 11 commits into
mainfrom
fix/composer-pulse-video-attachment
Open

fix(huddle): attach Pulse videos recorded from the composer#495
Dharp02 wants to merge 11 commits into
mainfrom
fix/composer-pulse-video-attachment

Conversation

@Dharp02

@Dharp02 Dharp02 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The composer reserves a PulseVault video with target 'library', so unlike the ticket flow (where the backend creates the attachment itself) nothing links the finished clip to the draft post server-side. The only thing that turned it into a composer attachment was a media.list poll gated on the QR modal being open, which never ran in the two paths that matter:

  • Native/mobile opens the Pulse deep link and returns early, so no modal is ever shown and nothing watches for the upload.
  • On desktop, closing the modal (including "Done") killed the interval, losing an upload that landed a moment later.

The reserved videoid also lived only in React state, which backgrounding the app for Pulse can wipe.

Persist the pending reservation to localStorage and run the watcher off that instead of the modal, re-checking on visibilitychange/focus so returning from Pulse attaches immediately. Move onAttach behind a ref so host re-renders no longer restart the poll timer before it fires, and make the attach idempotent across the poll and the direct TUS onSuccess. Add a "Waiting for your Pulse video..." status so the composer isn't silently doing nothing while the user is off recording.

Scope the persisted reservation per composer (pulseScope) so the always-mounted feed composer can't swallow a video recorded from an open edit composer or the clock plan/wrap-up composer.

The composer reserves a PulseVault video with target 'library', so unlike
the ticket flow (where the backend creates the attachment itself) nothing
links the finished clip to the draft post server-side. The only thing that
turned it into a composer attachment was a media.list poll gated on the QR
modal being open, which never ran in the two paths that matter:

  - Native/mobile opens the Pulse deep link and returns early, so no modal
    is ever shown and nothing watches for the upload.
  - On desktop, closing the modal (including "Done") killed the interval,
    losing an upload that landed a moment later.

The reserved videoid also lived only in React state, which backgrounding
the app for Pulse can wipe.

Persist the pending reservation to localStorage and run the watcher off
that instead of the modal, re-checking on visibilitychange/focus so
returning from Pulse attaches immediately. Move onAttach behind a ref so
host re-renders no longer restart the poll timer before it fires, and make
the attach idempotent across the poll and the direct TUS onSuccess. Add a
"Waiting for your Pulse video..." status so the composer isn't silently
doing nothing while the user is off recording.

Scope the persisted reservation per composer (pulseScope) so the
always-mounted feed composer can't swallow a video recorded from an open
edit composer or the clock plan/wrap-up composer.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

🚀 Preview Deployment Ready

Service URL
App https://mieweb-timehuddle-fix-composer-pulse-video-attachment.os.mieweb.org
API (Meteor) https://mieweb-timehuddle-fix-composer-pulse-video-attachment-api.os.mieweb.org

Preview auto-deletes when this PR is closed.

Dharp02 added 10 commits August 11, 2026 16:14
huddle.createPost/updatePost/publishPost authenticated via `this.userId`,
which only exists on a DDP session, so posting required a live WebSocket.
That is a bad assumption on mobile: WKWebView tears down its network
connections whenever the app is backgrounded — recording a Pulse video, for
one — and the socket dies without a close event. The write then hangs
against a half-open socket with no error and no timeout, so the Post button
silently does nothing while every wormhole call on the same host succeeds
(CapacitorHttp uses native URLSession, a separate stack).

Switch the three authoring methods to requireIdentity, which resolves a
bearer token first and falls back to this.userId, so DDP callers are
unaffected, and expose them over wormhole. The reads (getMyPostForDate,
getMyLatestDraft, ...) already work this way; the writes had just never been
migrated.

On the client, route the authoring calls through wormholeCall and
consolidate the five ad-hoc getDdpClient().call('huddle.createPost') sites
into huddleApi.createPost. The feed still updates in realtime from the DDP
subscription wherever one is live.

Typing the call surfaced a latent bug: postWrapUpAndClockOut lacked the
!gateTeamId guard its two sibling functions have, so the recovery branch
posted teamId: null and relied on the backend to reject it.

Tests: huddle-post-rest.test.ts covers the REST transport (identity from the
token, attachment round-trip, update, draft publish, unauthenticated and
non-member rejection); plan-gate.test.ts continues to cover DDP.
huddle.getPosts gated on `this.userId`, which is only populated for DDP
callers. The composer refreshes the feed over REST right after posting
(huddle.createPost is REST for the same reason — the WebView drops DDP
while backgrounded), and over the wormhole bridge the caller is a bearer
token with `this.userId` always null. Every REST refresh was rejected
with "Authentication required".

Use requireIdentity(), which resolves a bearer token first and falls back
to the DDP userId, then run the existing team-membership check against
the resolved identity. No authorization is relaxed — an unauthenticated
caller still throws, and non-members are still rejected.
Dev serves the WebView from the machine's LAN IP, which changes with
DHCP and can't be pinned in ROOT_URL or CORS_ORIGINS. Three separate
symptoms of that, fixed together:

- CORS: `_baseDomain` is derived from ROOT_URL and is meaningless for a
  dotted IPv4 host, so private-LAN origins never matched and CORS headers
  were skipped. Raw XHR/fetch from the WebView (tus-js-client video
  uploads) broke while native-bridge calls kept working, since those
  aren't subject to CORS. Allow RFC1918/loopback origins, dev only —
  gated on `!Meteor.isProduction`.

- iOS: native URLSession traffic to RFC1918 hosts is gated by Local
  Network Privacy, and without NSLocalNetworkUsageDescription iOS never
  prompts, so every wormholeCall fails with "unsatisfied (Local network
  prohibited)". NSAllowsLocalNetworking does not cover this — it is an
  ATS key governing HTTPS. Production talks to a public hostname, so no
  prompt is shown there.

- scripts/sync-lan-ip.sh reconciles the running pm2 stack against the
  current IP, restarting only on actual drift. Detection stays in
  ecosystem.config.cjs's detectLanIp() rather than being duplicated.

Info.plist also widens the camera usage string and adds a microphone
string, both required now that videos are recorded for messages.
… time

The host a file was uploaded through is not a property of the file.
Media URLs were persisted absolute, baked from ROOT_URL /
VITE_TIMECORE_URL at upload time, so every DHCP lease change orphaned
every previously-posted image and video, and a deployment moving
hostnames would do the same.

Writers now persist a path only (`/uploads/...`, `/pulsevault/...`), and
readers re-base it onto the origin the session actually talks to via the
new `resolveMediaUrl`. A stored host is treated as advisory, so posts
written before this change are repaired on read. URLs pointing anywhere
else (a real CDN, an external link) are left alone.

Re-based onto METEOR_API_BASE rather than METEOR_BASE_URL: on proxied web
dev that is '' — a same-origin path served through Vite's /uploads and
/pulsevault proxies, which works from any client on the network, not just
the machine running the backend. Native and explicit-URL builds still get
the absolute backend URL.

`/pulsevault/` also joins `toAbsoluteUrl`'s media prefixes; it previously
fell through to TIMECORE_BASE_URL, which was wrong for the media library
and profile feed.

Also in this change, since they touch the same call paths:

- pulsevault accepts `.mov`/`.m4v` (what the iOS camera roll and native
  picker hand back — ISO-BMFF, so createMp4Sniffer already accepts them)
  and corrects their Content-Type on the way out, since the package's
  ext→MIME map only knows `.mp4` and `<video>` refuses to play
  application/octet-stream inline. One VIDEO_CONTENT_TYPES map drives the
  allowed extensions, the served Content-Type, and the recorded
  mimeType/filename, so a .mov is no longer filed as an mp4.

- uploadMedia/uploadImage gain byte-progress callbacks (uploadImage moves
  from fetch to XHR, the only API exposing upload-side progress). The
  composer UI consuming these lands in the next commit.

- MarkdownContent renders pasted `data:` raster images and bounds
  full-resolution screenshots to the card. Only *relative* srcs are
  re-based: post markdown is user-authored, and an external image that
  merely happens to live under /uploads/ must not be rewritten onto our
  own origin.
Two gaps in the composer, sharing one new upload path:

Uploads were silent. A several-second video upload with no feedback is
indistinguishable from a broken button, and submitting mid-upload
stranded the attachment still on the wire. There is now a single bar
spanning both phases — determinate over real bytes while an attachment
uploads, then an eased estimate while the post itself is in flight — and
every submit path stays disabled until the upload lands. The pressed
attach button keeps its own spinner so it stays obvious *which*
attachment is in flight.

Pasting a screenshot went through Kerebron's handler, which embeds the
image inline as a base64 data: URL — hundreds of KB added to the post
document, and the file never reaches the media store. MarkdownEditor now
intercepts the paste with a capture-phase listener on its wrapper, which
is what guarantees it runs before ProseMirror's own listener on the
contenteditable below, and hands the files to the host to upload like any
other attachment.

Both the Photo/Video/Doc pickers and paste go through the new
useAttachmentUpload hook, so they report progress, surface failures, and
produce a MediaItem identically. AttachmentBar's three near-identical
buttons collapse into one map over a descriptor list.

ClockPage shares the composer, so it gets the same bar and the same
upload guard on its three submit paths — it previously had none, and
submitting mid-upload there would have dropped the attachment.

onPost is now awaited (Promise<void>), so the composer can hold its
fields until the post resolves and restore them if it fails, rather than
clearing optimistically.
On a short viewport the expanded composer is taller than the space
between the header and the fixed bottom nav, so its lower half — the
attach buttons, Cancel and Post — was clipped under the nav and
unreachable. The composer wrapper now shrinks and scrolls its own
overflow.

That breaks anchored menus: once one overflow axis is non-visible the CSS
overflow spec forces the other to clip too, so the Ticket and @Mention
menus opening below their triggers were silently hidden. `position:
absolute` cannot escape this. The new AnchoredMenu portals to <body> and
positions with fixed coordinates read from its trigger's rect, flips
above when there isn't room below, and exposes the available space as
max-height so long lists scroll inside it. Reposition is coalesced to one
frame per scroll burst, since scroll fires far faster than the screen
repaints and each run costs a layout-forcing getBoundingClientRect.

TicketPicker and MentionMenu drop their hand-rolled offset clamping and
click-outside handling in favour of it, and gain menu/menuitem roles plus
aria-haspopup/aria-expanded. The mention search input is deliberately no
longer autofocused: stealing focus on open scrolls the composer to reveal
it, which reads as the editor jumping, and pops the mobile keyboard over
the list you are trying to pick from.

Also here: addPost stops polling the DDP cache to decide whether its REST
refresh worked. refreshFeed overlays the new post immediately, so the
post is on screen after the first refresh; waiting on DDP specifically
meant burning the full 4.5s backoff on every post whenever the socket was
down — which is the exact case the REST overlay exists to cover. It now
retries only while the post is absent by either route.
…paste

Screenshot paste had no coverage at all, and nothing verified that
removing an attachment actually kept it out of the post.

New composer-paste.spec.ts covers paste-to-upload, paste into an empty
composer, multi-image paste, the progress bar and post-blocking, and a
guard that plain-text paste still reaches the editor. The load-bearing
assertions are the negative ones — no <img> in the editor, zero
img[src^="data:"] in the post — because a paste that silently fell back
to Kerebron's base64 handler would still *show* an image in the feed, so
asserting only "an image is shown" would pass against the exact bug this
replaced. Verified by unwiring onImagePaste and confirming all four
image-paste tests fail.

Playwright cannot put an image on the OS clipboard, so pasteFiles/
pasteText dispatch a real ClipboardEvent carrying a DataTransfer of Files
at the ProseMirror surface, which reaches the capture-phase listener the
way a real paste does.

composer-actions.spec.ts covers each attach action alone and all of them
in one post, plus a removal test that drops the *first* of two
attachments — dropping the last would pass against an off-by-one in the
filter. composer-responsive.spec.ts checks every control is reachable
across four viewports and that the composer scrolls rather than clips.

Shared helpers move to huddle/helpers.ts so each spec stops re-deriving
them slightly differently; pulsevault-video.spec.ts now uses them.

Fixture selectSharedTestTeam retries: writing the localStorage key is not
enough on its own, because TeamContext re-points the selection at the
personal team whenever the stored id isn't in the team list *yet*, so a
selection made while teams are still loading was silently reverted and
the test ran against a one-member personal feed.
Declares the Playwright MCP server so the browser-testing workflow
CLAUDE.md already calls for works without per-machine setup.
The Frontend Checks Format step (`prettier --check .`) has been failing
on this branch since 8af21ea. Reformatting only — method-chain and JSX
attribute wrapping in the two sources, markdown reflow in the plan doc.
`git diff -w` shows no semantic change.

Format runs before Typecheck and Build in the workflow, so its failure
was masking whether those pass; both are green locally.
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