fix(huddle): attach Pulse videos recorded from the composer - #495
Open
Dharp02 wants to merge 11 commits into
Open
fix(huddle): attach Pulse videos recorded from the composer#495Dharp02 wants to merge 11 commits into
Dharp02 wants to merge 11 commits into
Conversation
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.
🚀 Preview Deployment Ready
Preview auto-deletes when this PR is closed. |
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.
…load improvements
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.