+ );
+ })}
>
);
}
diff --git a/src/features/huddle/ComposerAttachments.tsx b/src/features/huddle/ComposerAttachments.tsx
index 7e5e9f7d..66e05bb1 100644
--- a/src/features/huddle/ComposerAttachments.tsx
+++ b/src/features/huddle/ComposerAttachments.tsx
@@ -18,6 +18,13 @@ interface ComposerAttachButtonsProps {
selectedTicketId?: string;
onTicketSelect: (ticketId: string) => void;
onMentionSelect: (userId: string, name: string) => void;
+ /**
+ * Stable id for this composer, so a Pulse recording started here resumes into
+ * *this* composer after the app is backgrounded — see {@link PulseAttachButton}.
+ */
+ pulseScope?: string;
+ /** Fraction (0–1) of an in-flight attachment upload, or null when idle. */
+ onUploadProgress?: (fraction: number | null) => void;
}
/** The Photo / Video / Doc / Pulse / Ticket / @Mention button row. */
@@ -27,11 +34,13 @@ export function ComposerAttachButtons({
selectedTicketId,
onTicketSelect,
onMentionSelect,
+ pulseScope,
+ onUploadProgress,
}: ComposerAttachButtonsProps) {
return (
<>
-
-
+
+
{teamId && (
)}
diff --git a/src/features/huddle/ComposerProgress.tsx b/src/features/huddle/ComposerProgress.tsx
new file mode 100644
index 00000000..775aa0a4
--- /dev/null
+++ b/src/features/huddle/ComposerProgress.tsx
@@ -0,0 +1,52 @@
+/**
+ * ComposerProgress — one full-width bar covering every phase of composing a
+ * post, shared by the Huddle composer and the Clock page's plan/wrap-up
+ * composer so both read identically.
+ *
+ * Uploads report real byte progress, so the bar is determinate there. Posting
+ * has no byte stream to measure, so it eases toward 83% (see the
+ * `huddle-progress` keyframes in styles.css) and snaps to 100% when the post
+ * resolves — honest about being an estimate while still moving.
+ */
+interface ComposerProgressProps {
+ /** 0–1 while an attachment is uploading, null otherwise. */
+ uploadFraction: number | null;
+ /** A post/publish request is in flight. */
+ posting: boolean;
+ /** That request has resolved — snap the bar to 100% before it disappears. */
+ postDone?: boolean;
+}
+
+export function ComposerProgress({
+ uploadFraction,
+ posting,
+ postDone = false,
+}: ComposerProgressProps) {
+ const uploading = uploadFraction !== null;
+ if (!uploading && !posting) return null;
+
+ const percent = uploading ? Math.round(uploadFraction * 100) : postDone ? 100 : undefined;
+
+ return (
+
+ );
+}
diff --git a/src/features/huddle/DraftsPanel.tsx b/src/features/huddle/DraftsPanel.tsx
index 7a19246c..4918c842 100644
--- a/src/features/huddle/DraftsPanel.tsx
+++ b/src/features/huddle/DraftsPanel.tsx
@@ -110,7 +110,7 @@ export function DraftsPanel({ teamId, userInitials, userColor }: DraftsPanelProp
void updateDraft(draft.id, content)}
+ onPost={(content) => updateDraft(draft.id, content)}
userInitials={userInitials}
userColor={userColor}
initialText={draft.content.text}
diff --git a/src/features/huddle/HuddleComposer.tsx b/src/features/huddle/HuddleComposer.tsx
index f29d87a0..621555aa 100644
--- a/src/features/huddle/HuddleComposer.tsx
+++ b/src/features/huddle/HuddleComposer.tsx
@@ -11,17 +11,20 @@
* editing an existing post must remount the composer with
* `key={editingPostId ?? 'new'}`.
*/
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useTeam } from '@lib/TeamContext';
import { attachmentApi } from '@lib/api';
import { MarkdownEditor } from './MarkdownEditor';
import { ComposerAttachButtons, ComposerChips, type MentionRef } from './ComposerAttachments';
+import { ComposerProgress } from './ComposerProgress';
+import { useAttachmentUpload } from './useAttachmentUpload';
+import { clearComposerPulseUpload } from './pulseComposerUpload';
import { huddlePostCollab } from './collab';
import type { ComposerContent, MediaItem } from './types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HuddleComposerProps {
- onPost: (content: ComposerContent) => void;
+ onPost: (content: ComposerContent) => Promise;
userInitials?: string;
userColor?: 'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green';
/**
@@ -77,9 +80,20 @@ export function HuddleComposer({
const [attachments, setAttachments] = useState(initialAttachments ?? []);
const [ticketVideos, setTicketVideos] = useState([]);
const [mentions, setMentions] = useState(initialMentions ?? []);
+ const [posting, setPosting] = useState(false);
+ const [postDone, setPostDone] = useState(false);
+ // Completed fraction (0–1) of an attachment upload in flight, or null when
+ // none is. Drives the same bar as posting does, so uploading a video and
+ // submitting the post read as one continuous operation.
+ const [uploadFraction, setUploadFraction] = useState(null);
const { selectedTeamId } = useTeam();
const composerRef = useRef(null);
+ // Stable localStorage scope for the Pulse upload button — also the key this
+ // composer clears once a post is submitted/cancelled so a finished video
+ // isn't re-attached to the next post.
+ const pulseScope = editing ? `huddle-edit-${collabRoom ?? 'post'}` : 'huddle-new';
+
// Click-outside → collapse (only when empty, so in-progress writing is never
// lost). Frees up feed space when you're not actively composing. Disabled in
// edit mode — the host owns dismissal there.
@@ -136,42 +150,51 @@ export function HuddleComposer({
};
}, [selectedTicketId]);
- const handleSubmit = () => {
- try {
- if (!text.trim() && attachments.length === 0 && ticketVideos.length === 0) return;
+ const hasContent = !!text.trim() || attachments.length > 0 || ticketVideos.length > 0;
+ // Posting mid-upload would drop the attachment still on the wire, so the
+ // button stays disabled until every attachment has landed.
+ const canSubmit = hasContent && !posting && uploadFraction === null;
- // Combine user attachments with ticket videos
- const allAttachments = [...attachments, ...ticketVideos];
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
- // RichEditor has no insert-at-cursor API, so append any selected mentions
- // that aren't already written in the body as trailing @name tokens —
- // otherwise they'd be tracked but never visible in the post.
- const base = text.trim();
- const mentionSuffix = mentions
- .filter((m) => !base.includes(`@${m.name}`))
- .map((m) => `@${m.name}`)
- .join(' ');
- const finalText = [base, mentionSuffix].filter(Boolean).join(' ') || '(Image post)';
+ const allAttachments = [...attachments, ...ticketVideos];
+ const base = text.trim();
+ const mentionSuffix = mentions
+ .filter((m) => !base.includes(`@${m.name}`))
+ .map((m) => `@${m.name}`)
+ .join(' ');
+ const finalText = [base, mentionSuffix].filter(Boolean).join(' ');
- onPost({
+ setPosting(true);
+ setPostDone(false);
+ try {
+ await onPost({
text: finalText,
json: { text: finalText },
ticketId: selectedTicketId,
attachments: allAttachments,
mentions,
});
- // In edit mode the host closes the composer once the update resolves; keep
- // the fields intact so nothing flickers before it unmounts.
- if (editing) return;
- setText(initialText);
- setExpanded(false);
- setSelectedTicketId(undefined);
- setAttachments([]);
- setTicketVideos([]);
- setMentions([]);
+ setPostDone(true);
+ // Hold at 100% briefly so the user sees the bar complete
+ await new Promise((r) => setTimeout(r, 400));
+ // In edit mode the host closes the composer once the update resolves
+ if (!editing) {
+ clearComposerPulseUpload(pulseScope);
+ setText(initialText);
+ setExpanded(false);
+ setSelectedTicketId(undefined);
+ setAttachments([]);
+ setTicketVideos([]);
+ setMentions([]);
+ }
} catch (error) {
console.error('[HuddleComposer] Error in handleSubmit:', error);
alert('Failed to post. Please try again.');
+ } finally {
+ setPosting(false);
+ setPostDone(false);
}
};
@@ -180,6 +203,7 @@ export function HuddleComposer({
onCancel?.();
return;
}
+ clearComposerPulseUpload(pulseScope);
setText(initialText);
setExpanded(false);
setSelectedTicketId(undefined);
@@ -188,9 +212,29 @@ export function HuddleComposer({
setMentions([]);
};
- const handleAttachmentAdd = (media: MediaItem) => setAttachments((prev) => [...prev, media]);
- const handleAttachmentRemove = (mediaId: string) =>
+ // useCallback: this is the hook's dependency, and through it the identity of
+ // the paste handler MarkdownEditor binds a native listener to. Unmemoized it
+ // would tear down and re-register that listener on every keystroke.
+ const handleAttachmentAdd = useCallback(
+ (media: MediaItem) =>
+ setAttachments((prev) => (prev.some((m) => m.id === media.id) ? prev : [...prev, media])),
+ [],
+ );
+ const handleAttachmentRemove = (mediaId: string) => {
+ // Removing the Pulse video chip also forgets its persisted upload, so it
+ // won't reappear when the composer remounts.
+ const removed = attachments.find((m) => m.id === mediaId);
+ if (removed?.type === 'video') clearComposerPulseUpload(pulseScope);
setAttachments((prev) => prev.filter((m) => m.id !== mediaId));
+ };
+
+ // Pasted screenshots go through the same upload as the Photo button, so they
+ // land in the media store and post as real attachments instead of being
+ // embedded in the post text as base64.
+ const { upload: uploadPastedImages } = useAttachmentUpload({
+ onAttachmentAdd: handleAttachmentAdd,
+ onUploadProgress: setUploadFraction,
+ });
// RichEditor has no insert-at-cursor API, so mentions are tracked as chips
// below the editor instead of injected inline.
@@ -245,6 +289,7 @@ export function HuddleComposer({
value={text}
onChange={setText}
onSubmit={handleSubmit}
+ onImagePaste={uploadPastedImages}
collab={huddlePostCollab(collabRoom)}
placeholder="What's on your mind?"
/>
@@ -286,10 +331,12 @@ export function HuddleComposer({
- {submitLabel}
+ {posting ? 'Posting…' : submitLabel}
+
+ {/* ── Progress bar — spans the composer for both phases: attachment
+ uploads (determinate, real bytes) and the post itself. ── */}
+
);
}
diff --git a/src/features/huddle/MarkdownContent.tsx b/src/features/huddle/MarkdownContent.tsx
index 6a7ecc4a..5a9a4066 100644
--- a/src/features/huddle/MarkdownContent.tsx
+++ b/src/features/huddle/MarkdownContent.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, memo } from 'react';
-import ReactMarkdown from 'react-markdown';
+import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
+import { resolveMediaUrl } from '@lib/api';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeHighlight from 'rehype-highlight';
@@ -54,6 +55,39 @@ const MermaidBlock = memo(function MermaidBlock({ code }: { code: string }) {
);
});
+// ─── Image URLs ───────────────────────────────────────────────────────────────
+/**
+ * Raster image data URLs, as produced by pasting a screenshot into the editor.
+ * SVG is deliberately excluded — it can carry markup, and there is no reason to
+ * paste one inline.
+ */
+const INLINE_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp|avif);base64,/i;
+
+/** Has a scheme (`https:`, `data:`) or is protocol-relative (`//host/…`). */
+const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
+
+/**
+ * react-markdown's `defaultUrlTransform` blanks any URL whose protocol isn't
+ * http/https/mailto/xmpp, so a pasted screenshot (stored inline as a `data:`
+ * URL) renders as `
![]()
` — a broken icon showing its alt text. Allow
+ * inline raster images through, and re-base *relative* backend media paths, so
+ * `/uploads/...` also resolves from the native app, whose origin is
+ * capacitor:// rather than the backend host.
+ *
+ * Absolute URLs are deliberately left alone. `resolveMediaUrl` re-bases any URL
+ * whose path starts with a backend media prefix regardless of host — safe for
+ * attachment records, where every URL is backend-owned by construction, but not
+ * here: post markdown is user-authored, and an external image that merely
+ * happens to live under `/uploads/` (`https://raw.githubusercontent.com/o/r/uploads/x.png`)
+ * would be rewritten onto our own origin and break.
+ */
+function transformUrl(url: string, key: string): string {
+ if (key === 'src' && INLINE_IMAGE_URL.test(url)) return url;
+ const safe = defaultUrlTransform(url);
+ if (key !== 'src' || !safe || ABSOLUTE_URL.test(safe)) return safe;
+ return resolveMediaUrl(safe);
+}
+
// ─── MarkdownContent ──────────────────────────────────────────────────────────
// memo() — only re-renders if the markdown string actually changes.
// This is the key fix: parent components (feed, composer) re-render all the
@@ -84,6 +118,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content }: { cont
"
>
+ );
+ },
code({ className, children, node, ...rest }) {
const code = String(children).trim();
const nodeClasses = (node?.properties?.className as string[]) ?? [];
diff --git a/src/features/huddle/MarkdownEditor.tsx b/src/features/huddle/MarkdownEditor.tsx
index b306a286..b896cd58 100644
--- a/src/features/huddle/MarkdownEditor.tsx
+++ b/src/features/huddle/MarkdownEditor.tsx
@@ -24,7 +24,7 @@
*/
import { RichEditor } from '@mieweb/ui/kerebron';
import type { CollabConfig } from '@mieweb/ui/kerebron';
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
interface MarkdownEditorProps {
value?: string;
@@ -36,6 +36,12 @@ interface MarkdownEditorProps {
collab?: CollabConfig;
/** Prompt shown while the editor is empty. */
placeholder?: string;
+ /**
+ * Called with image files pasted into the editor (e.g. a screenshot).
+ * When set, the paste is intercepted before the editor sees it — see the
+ * listener below for why.
+ */
+ onImagePaste?: (files: File[]) => void;
}
export function MarkdownEditor({
@@ -45,11 +51,41 @@ export function MarkdownEditor({
className,
collab,
placeholder,
+ onImagePaste,
}: MarkdownEditorProps) {
const isEmpty = value.trim().length === 0;
+ const containerRef = useRef
(null);
+
+ // Kerebron's paste handler embeds a pasted screenshot inline as a base64
+ // `data:` URL, so a single screenshot adds hundreds of KB to the post
+ // document and never reaches the media store. Intercept it first and hand
+ // the file to the host, which uploads it like any other attachment.
+ //
+ // A native capture-phase listener on this wrapper (rather than React's
+ // onPasteCapture) is what guarantees ordering: it runs while the event is
+ // still descending, before ProseMirror's own listener on the contenteditable
+ // below can see it.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !onImagePaste) return;
+
+ const handlePaste = (event: ClipboardEvent) => {
+ const files = Array.from(event.clipboardData?.files ?? []).filter((file) =>
+ file.type.startsWith('image/'),
+ );
+ if (files.length === 0) return; // plain text, links, …— let the editor handle it
+ event.preventDefault();
+ event.stopPropagation();
+ onImagePaste(files);
+ };
+
+ container.addEventListener('paste', handlePaste, true);
+ return () => container.removeEventListener('paste', handlePaste, true);
+ }, [onImagePaste]);
return (
([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
- // Horizontal offset (px) of the menu relative to the trigger, clamped so the
- // menu never spills past either viewport edge.
- const [offsetX, setOffsetX] = useState(0);
- const dropdownRef = useRef
(null);
+ const triggerRef = useRef(null);
useEffect(() => {
if (isOpen && members.length === 0 && teamId) {
@@ -23,19 +21,6 @@ export function MentionMenu({ teamId, onSelect }: MentionMenuProps) {
}
}, [isOpen, teamId]);
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setIsOpen(false);
- }
- };
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }
- }, [isOpen]);
-
const loadMembers = async () => {
if (!teamId) return;
@@ -56,30 +41,21 @@ export function MentionMenu({ teamId, onSelect }: MentionMenuProps) {
setSearchQuery('');
};
- const handleToggle = () => {
- if (!isOpen && dropdownRef.current) {
- const rect = dropdownRef.current.getBoundingClientRect();
- const margin = 8;
- const menuWidth = Math.min(288, window.innerWidth - margin * 2); // w-72
- let viewportLeft = rect.left;
- if (viewportLeft + menuWidth > window.innerWidth - margin) {
- viewportLeft = window.innerWidth - margin - menuWidth;
- }
- if (viewportLeft < margin) viewportLeft = margin;
- setOffsetX(viewportLeft - rect.left);
- }
- setIsOpen((prev) => !prev);
- };
+ const handleClose = useCallback(() => setIsOpen(false), []);
const filteredMembers = members.filter((member) =>
member.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
-
+ <>
setIsOpen((prev) => !prev)}
disabled={!teamId}
+ aria-haspopup="menu"
+ aria-expanded={isOpen}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-neutral-400 border border-gray-200 dark:border-neutral-700 px-3 py-1.5 rounded-full hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -93,65 +69,71 @@ export function MentionMenu({ teamId, onSelect }: MentionMenuProps) {
@Mention
- {isOpen && (
-
- {/* Search input */}
-
- setSearchQuery(e.target.value)}
- placeholder="Search members..."
- autoFocus
- className="w-full bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-lg px-3 py-2 text-xs text-gray-700 dark:text-neutral-300 placeholder:text-gray-400 dark:placeholder:text-neutral-600 outline-none focus:border-indigo-400 dark:focus:border-indigo-500 transition-colors"
- />
-
+
+ {/* Search input — deliberately not autofocused: stealing focus on open
+ scrolls the composer's scroll container to reveal the input, which
+ reads as the editor jumping upward (and pops the mobile keyboard
+ over the very list you're trying to pick from). */}
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search members..."
+ className="w-full bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-lg px-3 py-2 text-xs text-gray-700 dark:text-neutral-300 placeholder:text-gray-400 dark:placeholder:text-neutral-600 outline-none focus:border-indigo-400 dark:focus:border-indigo-500 transition-colors"
+ />
+
- {/* Members list */}
-
- {loading ? (
-
- Loading members...
-
- ) : filteredMembers.length === 0 ? (
-
- No members found
-
- ) : (
- filteredMembers.map((member) => (
-
handleSelect(member.id, member.name)}
- className="w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors text-gray-700 dark:text-neutral-300"
- >
-
- {member.image ? (
-

- ) : (
-
- {member.name.substring(0, 2).toUpperCase()}
-
- )}
-
-
{member.name}
-
- {member.email}
-
+ {/* Members list */}
+
+ {loading ? (
+
+ Loading members...
+
+ ) : filteredMembers.length === 0 ? (
+
+ No members found
+
+ ) : (
+ filteredMembers.map((member) => (
+
handleSelect(member.id, member.name)}
+ className="w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors text-gray-700 dark:text-neutral-300"
+ >
+
+ {member.image ? (
+

+ ) : (
+
+ {member.name.substring(0, 2).toUpperCase()}
+
+ )}
+
+
{member.name}
+
+ {member.email}
-
- ))
- )}
-
+
+
+ ))
+ )}
- )}
-
+
+ >
);
}
diff --git a/src/features/huddle/PostCard/index.tsx b/src/features/huddle/PostCard/index.tsx
index 5ba320ab..c217a4f7 100644
--- a/src/features/huddle/PostCard/index.tsx
+++ b/src/features/huddle/PostCard/index.tsx
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import type { HuddlePost } from '@lib/api';
-import { huddleApi, METEOR_BASE_URL } from '@lib/api';
+import { huddleApi, resolveMediaUrl } from '@lib/api';
import { MarkdownContent } from '../MarkdownContent';
import { HuddleComments } from '../HuddleComments';
import { HuddleComposer } from '../HuddleComposer';
@@ -11,11 +11,10 @@ import { Share } from '@capacitor/share';
import { Capacitor } from '@capacitor/core';
// ── URL Resolution ────────────────────────────────────────────────────────────
-function resolveAttachmentUrl(url: string | undefined): string {
- if (!url) return '';
- if (/^https?:\/\//i.test(url)) return url;
- return `${METEOR_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
-}
+// Posts persist attachment URLs by path; `resolveMediaUrl` binds them to
+// whichever backend origin is serving this session (and repairs older posts
+// that stored an absolute URL against a host the backend has since left).
+const resolveAttachmentUrl = resolveMediaUrl;
// ── Avatar ────────────────────────────────────────────────────────────────────
type AvatarColor = 'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green';
@@ -254,7 +253,10 @@ export function PostCard({
{/* ── Ticket badge ── */}
{post.ticketId && (
@@ -272,7 +274,9 @@ export function PostCard({
/>
-
+ {/* min-w-0: a flex item defaults to min-width:auto and would otherwise
+ refuse to shrink below its text, defeating `truncate`. */}
+
{post.ticketTitle || 'Linked Ticket'}
@@ -300,12 +304,12 @@ export function PostCard({
onCancel={handleCancelEdit}
/>
- ) : (
- // ── Render markdown instead of plain text ──
+ ) : // ── Render markdown instead of plain text ──
+ post.content.text?.trim() ? (
- )}
+ ) : null}
{/* ── Attachments ── */}
{!isEditing && post.attachments && post.attachments.length > 0 && (
diff --git a/src/features/huddle/PulseAttachButton.tsx b/src/features/huddle/PulseAttachButton.tsx
index ff4777ad..b572b9a5 100644
--- a/src/features/huddle/PulseAttachButton.tsx
+++ b/src/features/huddle/PulseAttachButton.tsx
@@ -1,9 +1,9 @@
import { faQrcode, faVideo } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as tus from 'tus-js-client';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { mediaApi, videoApi, METEOR_BASE_URL } from '../../lib/api';
+import { mediaApi, videoApi } from '../../lib/api';
import {
getStoreOS,
isNativeApp,
@@ -13,22 +13,27 @@ import {
import type { MediaItem } from './types';
import { buildUploadDeepLink } from '../media/PulseUploadButton';
import { PulseUploadModal } from '../media/PulseUploadModal';
+import {
+ PENDING_TTL_MS,
+ POLL_INTERVAL_MS,
+ persistDone,
+ readPending,
+ videoMediaItem,
+ writePending,
+ clearComposerPulseUpload,
+ type PendingUpload,
+} from './pulseComposerUpload';
interface PulseAttachButtonProps {
/** Called with the recorded/uploaded video as a composer attachment. */
onAttach: (media: MediaItem) => void;
-}
-
-/** Build a composer MediaItem for a finished PulseVault video. */
-function videoMediaItem(videoid: string, filename: string, size: number): MediaItem {
- return {
- id: videoid,
- type: 'video',
- size,
- mimeType: 'video/mp4',
- url: `${METEOR_BASE_URL.replace(/\/$/, '')}/pulsevault/artifacts/${videoid}`,
- filename,
- };
+ /**
+ * Identifies which composer this button belongs to, so a pending reservation
+ * is resumed by that composer only (the feed composer must not swallow a
+ * video recorded from an open edit composer). Also the localStorage key
+ * suffix, so it has to be stable across remounts/reloads of the same composer.
+ */
+ scope?: string;
}
/**
@@ -38,41 +43,107 @@ function videoMediaItem(videoid: string, filename: string, size: number): MediaI
* back to the composer as an attachment.
*
* A library upload completing on the backend inserts a media-library item keyed
- * by `videoid`, so the QR/phone flow is detected by polling `media.list` for the
- * reserved id (there's no ticket attachment list to watch).
+ * by `videoid`, so the phone/QR flow is detected by polling `media.list` for the
+ * reserved id (there's no ticket attachment list to watch). Unlike the ticket
+ * flow — where the backend attaches the video itself — nothing links the clip to
+ * the composer server-side, so the watcher runs off a persisted reservation
+ * rather than off the QR modal being open.
*/
-export const PulseAttachButton: React.FC = ({ onAttach }) => {
+export const PulseAttachButton: React.FC = ({
+ onAttach,
+ scope = 'default',
+}) => {
const isNative = isNativeApp();
const fileInputRef = useRef(null);
+ // Keeps the poll interval off the render cycle — `onAttach` is redefined by
+ // the host on every render, and depending on it would restart the timer
+ // before it ever fires.
+ const onAttachRef = useRef(onAttach);
+ onAttachRef.current = onAttach;
+ const attachedRef = useRef(null);
const [modalOpen, setModalOpen] = useState(false);
const [uploadLink, setUploadLink] = useState(null);
- const [videoid, setVideoid] = useState(null);
const [uploadToken, setUploadToken] = useState(null);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const [reserving, setReserving] = useState(false);
+ const [pending, setPending] = useState(() => readPending(scope));
+
+ const videoid = pending?.videoid ?? null;
+
+ /** Hand the finished video to the composer exactly once. */
+ const finishAttach = useCallback(
+ (id: string, filename: string, size: number) => {
+ if (attachedRef.current === id) return;
+ attachedRef.current = id;
+ const media = videoMediaItem(id, filename, size);
+ // Keep the finished video persisted (not cleared) so a reload-remount can
+ // restore it; the host clears it on post/cancel via clearComposerPulseUpload.
+ persistDone(scope, id, media);
+ setPending({ videoid: id, reservedAt: Date.now(), done: media });
+ setModalOpen(false);
+ setError(null);
+ onAttachRef.current(media);
+ },
+ [scope],
+ );
+
+ // Re-attach a video that finished before a WebView reload wiped the composer
+ // state (returning from the Pulse app remounts this button with the persisted
+ // `done` record). attachedRef guards against a same-session double-emit.
+ const doneEmittedRef = useRef(false);
+ useEffect(() => {
+ if (doneEmittedRef.current || !pending?.done) return;
+ if (attachedRef.current === pending.videoid) return;
+ doneEmittedRef.current = true;
+ attachedRef.current = pending.videoid;
+ onAttachRef.current(pending.done);
+ }, [pending]);
- // Poll the media library while the QR modal is open to detect a phone upload
- // of the reserved videoid.
+ // Watch the media library for the reserved videoid until it appears. Runs
+ // whether or not the QR modal is open, and survives the app being
+ // backgrounded by the Pulse deep link (the reservation is in localStorage).
useEffect(() => {
- if (!modalOpen || !videoid) return;
- const interval = setInterval(async () => {
+ if (!pending || pending.done) return;
+ let cancelled = false;
+
+ const check = async () => {
+ if (cancelled || document.hidden) return;
+ if (Date.now() - pending.reservedAt > PENDING_TTL_MS) {
+ clearComposerPulseUpload(scope);
+ setPending(null);
+ return;
+ }
try {
const items = await mediaApi.list();
- const match = items.find((m) => m.videoid === videoid || m.id === videoid);
- if (match) {
- clearInterval(interval);
- setModalOpen(false);
- onAttach(videoMediaItem(videoid, match.filename ?? `${videoid}.mp4`, match.size ?? 0));
- }
+ const match = items.find((m) => m.videoid === pending.videoid || m.id === pending.videoid);
+ if (!match || cancelled) return;
+ finishAttach(pending.videoid, match.filename ?? `${pending.videoid}.mp4`, match.size ?? 0);
} catch {
// ignore transient polling errors
}
- }, 3000);
- return () => clearInterval(interval);
- }, [modalOpen, videoid, onAttach]);
+ };
+
+ // Returning from the Pulse app fires a visibility change — check straight
+ // away instead of waiting out the interval.
+ const onVisible = () => {
+ if (!document.hidden) void check();
+ };
+
+ void check();
+ const interval = setInterval(() => void check(), POLL_INTERVAL_MS);
+ document.addEventListener('visibilitychange', onVisible);
+ window.addEventListener('focus', onVisible);
+
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ document.removeEventListener('visibilitychange', onVisible);
+ window.removeEventListener('focus', onVisible);
+ };
+ }, [pending, scope, finishAttach]);
const doReserve = async (): Promise<{ videoid: string; uploadLink: string } | null> => {
setReserving(true);
@@ -80,7 +151,8 @@ export const PulseAttachButton: React.FC = ({ onAttach }
try {
const { videoid, uploadToken } = await videoApi.reserveForLibrary();
const link = buildUploadDeepLink(videoid, uploadToken);
- setVideoid(videoid);
+ attachedRef.current = null;
+ setPending(writePending(scope, videoid));
setUploadToken(uploadToken);
setUploadLink(link);
return { videoid, uploadLink: link };
@@ -110,6 +182,13 @@ export const PulseAttachButton: React.FC = ({ onAttach }
setModalOpen(true);
};
+ const handleCancelPending = () => {
+ clearComposerPulseUpload(scope);
+ setPending(null);
+ setUploadToken(null);
+ setUploadLink(null);
+ };
+
const handleFileChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
e.target.value = '';
@@ -120,7 +199,8 @@ export const PulseAttachButton: React.FC = ({ onAttach }
const upload = new tus.Upload(file, {
endpoint: videoApi.uploadEndpoint(),
- retryDelays: [0, 3000, 5000, 10000],
+ retryDelays: videoApi.uploadRetryDelays,
+ onShouldRetry: videoApi.shouldRetryUpload,
metadata: { filename: file.name, filetype: file.type, videoid },
headers: { Authorization: `Bearer ${uploadToken}` },
onProgress(bytesUploaded, bytesTotal) {
@@ -129,11 +209,27 @@ export const PulseAttachButton: React.FC = ({ onAttach }
onSuccess() {
setUploadToken(null);
setProgress(null);
- onAttach(videoMediaItem(videoid, file.name, file.size));
+ finishAttach(videoid, file.name, file.size);
},
- onError(err) {
- setError(err instanceof Error ? err.message : 'Upload failed. Try again.');
+ async onError(err) {
+ // The backend finalizes the upload the moment the first PATCH completes,
+ // so a duplicate-PATCH 409 (or other late error) can fire even though the
+ // video already landed. Mirror the ticket flow's server-authoritative
+ // behavior: re-check the library before surfacing a failure. The pending
+ // watcher stays running as a second safety net.
setProgress(null);
+ try {
+ const items = await mediaApi.list();
+ const match = items.find((m) => m.videoid === videoid || m.id === videoid);
+ if (match) {
+ setUploadToken(null);
+ finishAttach(videoid, match.filename ?? file.name, match.size ?? file.size);
+ return;
+ }
+ } catch {
+ // fall through to surfacing the original error
+ }
+ setError(err instanceof Error ? err.message : 'Upload failed. Try again.');
},
});
@@ -146,6 +242,7 @@ export const PulseAttachButton: React.FC = ({ onAttach }
};
const isUploading = progress !== null;
+ const isWaiting = !!pending && !pending.done && !isUploading && !modalOpen;
return (
<>
@@ -170,6 +267,24 @@ export const PulseAttachButton: React.FC = ({ onAttach }
{reserving ? 'Preparing…' : isUploading ? `${progress}%` : 'Pulse'}
+ {isWaiting && (
+
+ Waiting for your Pulse video…
+
+ Cancel
+
+
+ )}
+
{error && (
{error}
diff --git a/src/features/huddle/TicketPicker.tsx b/src/features/huddle/TicketPicker.tsx
index 9cb96823..520ea195 100644
--- a/src/features/huddle/TicketPicker.tsx
+++ b/src/features/huddle/TicketPicker.tsx
@@ -1,4 +1,5 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { AnchoredMenu } from '@ui/AnchoredMenu';
import { fetchTeamTickets } from './api';
import type { Ticket } from './types';
@@ -13,10 +14,7 @@ export function TicketPicker({ teamId, onSelect, selectedId }: TicketPickerProps
const [tickets, setTickets] = useState([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
- // Horizontal offset (px) of the menu relative to the trigger, clamped so the
- // menu never spills past either viewport edge.
- const [offsetX, setOffsetX] = useState(0);
- const dropdownRef = useRef(null);
+ const triggerRef = useRef(null);
useEffect(() => {
// Re-fetches on every open (and if teamId resolves/changes while open) —
@@ -28,19 +26,6 @@ export function TicketPicker({ teamId, onSelect, selectedId }: TicketPickerProps
}
}, [isOpen, teamId]);
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setIsOpen(false);
- }
- };
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }
- }, [isOpen]);
-
const loadTickets = async () => {
console.log('[TicketPicker] loadTickets called, teamId:', teamId);
@@ -70,30 +55,21 @@ export function TicketPicker({ teamId, onSelect, selectedId }: TicketPickerProps
setSearchQuery('');
};
- const handleToggle = () => {
- if (!isOpen && dropdownRef.current) {
- const rect = dropdownRef.current.getBoundingClientRect();
- const margin = 8;
- const menuWidth = Math.min(320, window.innerWidth - margin * 2); // w-80
- let viewportLeft = rect.left;
- if (viewportLeft + menuWidth > window.innerWidth - margin) {
- viewportLeft = window.innerWidth - margin - menuWidth;
- }
- if (viewportLeft < margin) viewportLeft = margin;
- setOffsetX(viewportLeft - rect.left);
- }
- setIsOpen((prev) => !prev);
- };
+ const handleClose = useCallback(() => setIsOpen(false), []);
const filteredTickets = tickets.filter((ticket) =>
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
-
+ <>
setIsOpen((prev) => !prev)}
disabled={!teamId}
+ aria-haspopup="menu"
+ aria-expanded={isOpen}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-neutral-400 border border-gray-200 dark:border-neutral-700 px-3 py-1.5 rounded-full hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -107,70 +83,74 @@ export function TicketPicker({ teamId, onSelect, selectedId }: TicketPickerProps
Ticket
- {isOpen && (
-
- {/* Search input */}
-
- setSearchQuery(e.target.value)}
- placeholder="Search tickets..."
- className="w-full bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-lg px-3 py-2 text-xs text-gray-700 dark:text-neutral-300 placeholder:text-gray-400 dark:placeholder:text-neutral-600 outline-none focus:border-indigo-400 dark:focus:border-indigo-500 transition-colors"
- />
-
+
+ {/* Search input */}
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search tickets..."
+ className="w-full bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-lg px-3 py-2 text-xs text-gray-700 dark:text-neutral-300 placeholder:text-gray-400 dark:placeholder:text-neutral-600 outline-none focus:border-indigo-400 dark:focus:border-indigo-500 transition-colors"
+ />
+
- {/* Ticket list */}
-
- {loading ? (
-
- Loading tickets...
-
- ) : filteredTickets.length === 0 ? (
-
- No tickets found
-
- ) : (
- filteredTickets.map((ticket) => (
-
handleSelect(ticket.id)}
- className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors ${
- selectedId === ticket.id
- ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
- : 'text-gray-700 dark:text-neutral-300'
- }`}
- >
-
-
-
-
-
{ticket.title}
- {ticket.status && (
-
- {ticket.status}
-
- )}
-
-
- ))
- )}
-
+ {/* Ticket list */}
+
+ {loading ? (
+
+ Loading tickets...
+
+ ) : filteredTickets.length === 0 ? (
+
+ No tickets found
+
+ ) : (
+ filteredTickets.map((ticket) => (
+
handleSelect(ticket.id)}
+ className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors ${
+ selectedId === ticket.id
+ ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
+ : 'text-gray-700 dark:text-neutral-300'
+ }`}
+ >
+
+
+
+
+
{ticket.title}
+ {ticket.status && (
+
+ {ticket.status}
+
+ )}
+
+
+ ))
+ )}
- )}
-
+
+ >
);
}
diff --git a/src/features/huddle/api.ts b/src/features/huddle/api.ts
index f4478d64..8e585375 100644
--- a/src/features/huddle/api.ts
+++ b/src/features/huddle/api.ts
@@ -1,11 +1,29 @@
// Huddle feature API helpers
-import { teamApi, ticketApi, mediaApi, videoApi, METEOR_BASE_URL } from '@lib/api';
+import { teamApi, ticketApi, mediaApi, videoApi } from '@lib/api';
import type { HuddlePost } from '@lib/api';
import * as tus from 'tus-js-client';
import type { TeamMember, MediaItem } from './types';
export type PostAttachment = HuddlePost['attachments'][number];
+/**
+ * Strip the origin from a backend media URL so posts persist the path only.
+ *
+ * The host a file was uploaded through is not a property of the file: dev is
+ * served from a LAN IP that changes with the DHCP lease, and deployments move
+ * between hostnames. Persisting the origin freezes a post's media to whatever
+ * address the backend happened to answer on that day. Readers re-attach the
+ * current origin via `resolveMediaUrl`.
+ */
+function toMediaPath(url: string): string {
+ try {
+ const parsed = new URL(url, 'http://placeholder.invalid');
+ return `${parsed.pathname}${parsed.search}`;
+ } catch {
+ return url;
+ }
+}
+
/**
* Convert a composer MediaItem into the attachment shape stored on a post.
*/
@@ -25,7 +43,7 @@ export function toPostAttachment(media: MediaItem): PostAttachment {
return {
mediaId: media.id,
type,
- url: media.url,
+ url: toMediaPath(media.url),
filename: media.filename,
};
}
@@ -60,55 +78,60 @@ export async function fetchTeamTickets(teamId: string) {
}
}
+/** Fraction (0–1) of an in-flight upload, reported as bytes go out. */
+export type UploadProgress = (fraction: number) => void;
+
/**
- * Upload a media file (photo, video, doc)
+ * Upload a media file (photo, video, doc).
+ *
+ * Videos stream to PulseVault over TUS; images and documents go to Meteor's
+ * multipart media endpoint. Both report byte progress through `onProgress` so
+ * the composer can show one progress bar regardless of which path a file took
+ * — a several-second video upload with no feedback is indistinguishable from a
+ * broken button.
*/
-export async function uploadMedia(file: File): Promise
{
- let media: MediaItem;
+export async function uploadMedia(file: File, onProgress?: UploadProgress): Promise {
+ if (!file.type.startsWith('video/')) {
+ const item = await mediaApi.uploadImage(file, onProgress);
+ onProgress?.(1);
+ return item;
+ }
- if (file.type.startsWith('video/')) {
- // Videos go through PulseVault TUS
- const { videoid, uploadToken } = await videoApi.reserveForLibrary();
+ // Videos go through PulseVault TUS
+ const { videoid, uploadToken } = await videoApi.reserveForLibrary();
- await new Promise((resolve, reject) => {
- const upload = new tus.Upload(file, {
- endpoint: videoApi.uploadEndpoint(),
- retryDelays: [0, 3000, 5000, 10000],
- metadata: {
- filename: file.name,
- filetype: file.type,
- videoid,
- },
- headers: { Authorization: `Bearer ${uploadToken}` },
- onProgress(bytesUploaded, bytesTotal) {
- console.log(
- `[uploadMedia] Video upload: ${Math.round((bytesUploaded / bytesTotal) * 100)}%`,
- );
- },
- onSuccess() {
- resolve();
- },
- onError(err) {
- reject(err);
- },
- });
- upload.start();
+ await new Promise((resolve, reject) => {
+ const upload = new tus.Upload(file, {
+ endpoint: videoApi.uploadEndpoint(),
+ retryDelays: videoApi.uploadRetryDelays,
+ onShouldRetry: videoApi.shouldRetryUpload,
+ metadata: {
+ filename: file.name,
+ filetype: file.type,
+ videoid,
+ },
+ headers: { Authorization: `Bearer ${uploadToken}` },
+ onProgress(bytesUploaded, bytesTotal) {
+ if (bytesTotal > 0) onProgress?.(bytesUploaded / bytesTotal);
+ },
+ onSuccess() {
+ onProgress?.(1);
+ resolve();
+ },
+ onError(err) {
+ reject(err);
+ },
});
+ upload.start();
+ });
- // Build MediaItem shape from the uploaded video
- const videoUrl = `${METEOR_BASE_URL.replace(/\/$/, '')}/pulsevault/artifacts/${videoid}`;
- media = {
- id: videoid,
- type: 'video',
- size: file.size,
- mimeType: file.type,
- url: videoUrl,
- filename: file.name,
- };
- } else {
- // Images and documents go through Meteor media upload
- media = await mediaApi.uploadImage(file);
- }
-
- return media;
+ return {
+ id: videoid,
+ type: 'video',
+ size: file.size,
+ mimeType: file.type,
+ // Path only — the reader binds it to the current backend origin.
+ url: `/pulsevault/artifacts/${videoid}`,
+ filename: file.name,
+ };
}
diff --git a/src/features/huddle/pulseComposerUpload.ts b/src/features/huddle/pulseComposerUpload.ts
new file mode 100644
index 00000000..7f202527
--- /dev/null
+++ b/src/features/huddle/pulseComposerUpload.ts
@@ -0,0 +1,106 @@
+// Persistence for the Huddle composer's Pulse upload.
+//
+// A library reservation is only linked to the composer in client state, so
+// anything that tears that state down between "reserve" and "upload finished"
+// loses the video: on mobile the Pulse deep link backgrounds (and can reload)
+// the app, and on desktop closing the QR modal used to stop the watcher. Both
+// are the normal flow, so the pending videoid — and the resolved video once it
+// finishes — are persisted and restored on remount.
+//
+// Kept out of PulseAttachButton.tsx on purpose: a file that exports a React
+// component must export *only* components, or Vite's react plugin disables Fast
+// Refresh and forces a full page reload on every HMR update (which itself aborts
+// in-flight uploads). These are plain functions, so they live here.
+import type { MediaItem } from './types';
+
+const PENDING_STORAGE_PREFIX = 'pulsevault:composer:';
+/** Give a recording session plenty of time, but don't poll forever. */
+export const PENDING_TTL_MS = 30 * 60 * 1000;
+export const POLL_INTERVAL_MS = 4000;
+
+export interface PendingUpload {
+ videoid: string;
+ reservedAt: number;
+ /**
+ * Set once the upload finishes. Persisting the resolved media item (not just
+ * the reservation) lets the composer re-attach the video after the WebView
+ * reloads on return from the Pulse app — the reservation alone is consumed
+ * the moment the video is attached, so without this the finished clip is lost.
+ */
+ done?: MediaItem;
+}
+
+function pendingKey(scope: string): string {
+ return `${PENDING_STORAGE_PREFIX}${scope}`;
+}
+
+export function readPending(scope: string): PendingUpload | null {
+ try {
+ const raw = localStorage.getItem(pendingKey(scope));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as PendingUpload;
+ if (!parsed?.videoid || Date.now() - parsed.reservedAt > PENDING_TTL_MS) {
+ localStorage.removeItem(pendingKey(scope));
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function writePending(scope: string, videoid: string): PendingUpload {
+ const pending: PendingUpload = { videoid, reservedAt: Date.now() };
+ try {
+ localStorage.setItem(pendingKey(scope), JSON.stringify(pending));
+ } catch {
+ // localStorage may be unavailable in some native contexts — the in-memory
+ // watcher still covers the same-session case.
+ }
+ return pending;
+}
+
+function clearPending(scope: string): void {
+ try {
+ localStorage.removeItem(pendingKey(scope));
+ } catch {
+ // ignore
+ }
+}
+
+/** Persist a finished video so a remounted composer can re-attach it. */
+export function persistDone(scope: string, videoid: string, media: MediaItem): void {
+ try {
+ const pending: PendingUpload = { videoid, reservedAt: Date.now(), done: media };
+ localStorage.setItem(pendingKey(scope), JSON.stringify(pending));
+ } catch {
+ // ignore — same-session state still holds the attachment.
+ }
+}
+
+/**
+ * Forget any persisted Pulse upload for a composer scope. The host calls this
+ * once the post is submitted/cancelled (or the video chip is removed) so a
+ * finished clip isn't re-attached to the next post.
+ */
+export function clearComposerPulseUpload(scope: string): void {
+ clearPending(scope);
+}
+
+/**
+ * Build a composer MediaItem for a finished PulseVault video.
+ *
+ * The URL is a path, not an absolute address: this item is persisted to
+ * localStorage and can be restored days later on a backend that has since
+ * moved hosts. Readers bind it to the current origin via `resolveMediaUrl`.
+ */
+export function videoMediaItem(videoid: string, filename: string, size: number): MediaItem {
+ return {
+ id: videoid,
+ type: 'video',
+ size,
+ mimeType: 'video/mp4',
+ url: `/pulsevault/artifacts/${videoid}`,
+ filename,
+ };
+}
diff --git a/src/features/huddle/superChatFeed.ts b/src/features/huddle/superChatFeed.ts
index 2dbd6ffb..c7edd2a4 100644
--- a/src/features/huddle/superChatFeed.ts
+++ b/src/features/huddle/superChatFeed.ts
@@ -7,6 +7,7 @@
* attachments become plain links. Comments deliberately stay in the classic
* card view — SuperChat has no per-message thread concept.
*/
+import { resolveMediaUrl } from '@lib/api';
import type { HuddlePost } from '@lib/api';
import type {
Participant,
@@ -16,8 +17,11 @@ import type {
function attachmentMarkdown(att: HuddlePost['attachments'][number]): string {
const name = att.filename ?? 'attachment';
- if (att.type === 'image') return ``;
- return `[📎 ${name}](${att.url})`;
+ // Posts store attachment URLs by path — bind them to the current backend
+ // origin, same as the card view does (see PostCard).
+ const url = resolveMediaUrl(att.url);
+ if (att.type === 'image') return ``;
+ return `[📎 ${name}](${url})`;
}
/** Message text = post markdown + ticket tag + attachment embeds/links. */
diff --git a/src/features/huddle/useAttachmentUpload.ts b/src/features/huddle/useAttachmentUpload.ts
new file mode 100644
index 00000000..6d8c70e1
--- /dev/null
+++ b/src/features/huddle/useAttachmentUpload.ts
@@ -0,0 +1,47 @@
+/**
+ * One upload path for everything the composer can attach — the Photo/Video/Doc
+ * pickers and screenshots pasted straight into the editor — so both report
+ * progress, surface failures, and hand back a {@link MediaItem} identically.
+ */
+import { useCallback, useState } from 'react';
+import { uploadMedia } from './api';
+import type { MediaItem } from './types';
+
+interface UseAttachmentUploadOptions {
+ onAttachmentAdd: (media: MediaItem) => void;
+ /**
+ * Called with the in-flight upload's completed fraction (0–1), then `null`
+ * once it settles (success or failure).
+ */
+ onUploadProgress?: (fraction: number | null) => void;
+}
+
+export function useAttachmentUpload({
+ onAttachmentAdd,
+ onUploadProgress,
+}: UseAttachmentUploadOptions) {
+ const [uploading, setUploading] = useState(false);
+
+ /** Uploads sequentially, so the shared progress bar tracks one file at a time. */
+ const upload = useCallback(
+ async (files: File[]) => {
+ for (const file of files) {
+ setUploading(true);
+ onUploadProgress?.(0);
+ try {
+ const media = await uploadMedia(file, (fraction) => onUploadProgress?.(fraction));
+ onAttachmentAdd(media);
+ } catch (error) {
+ console.error('[useAttachmentUpload] Upload failed:', error);
+ alert(error instanceof Error ? error.message : 'Upload failed. Please try again.');
+ } finally {
+ setUploading(false);
+ onUploadProgress?.(null);
+ }
+ }
+ },
+ [onAttachmentAdd, onUploadProgress],
+ );
+
+ return { upload, uploading };
+}
diff --git a/src/features/media/MediaPage.tsx b/src/features/media/MediaPage.tsx
index 0ea4eb7e..f01e5fb6 100644
--- a/src/features/media/MediaPage.tsx
+++ b/src/features/media/MediaPage.tsx
@@ -56,7 +56,8 @@ async function uploadVideoToLibrary(file: File, onProgress: (pct: number) => voi
await new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: videoApi.uploadEndpoint(),
- retryDelays: [0, 3000, 5000],
+ retryDelays: videoApi.uploadRetryDelays,
+ onShouldRetry: videoApi.shouldRetryUpload,
metadata: { videoid, filename: file.name, filetype: file.type },
headers: { Authorization: `Bearer ${uploadToken}` },
onProgress(bytesUploaded, bytesTotal) {
diff --git a/src/features/media/PulseUploadButton.tsx b/src/features/media/PulseUploadButton.tsx
index 001ec3b9..1cc55204 100644
--- a/src/features/media/PulseUploadButton.tsx
+++ b/src/features/media/PulseUploadButton.tsx
@@ -177,7 +177,8 @@ export const PulseUploadButton: React.FC = ({
const upload = new tus.Upload(file, {
endpoint: videoApi.uploadEndpoint(),
- retryDelays: [0, 3000, 5000, 10000],
+ retryDelays: videoApi.uploadRetryDelays,
+ onShouldRetry: videoApi.shouldRetryUpload,
metadata: { filename: file.name, filetype: file.type, videoid },
headers: { Authorization: `Bearer ${uploadToken}` },
onProgress(bytesUploaded, bytesTotal) {
diff --git a/src/features/profile/ProfileFeed.tsx b/src/features/profile/ProfileFeed.tsx
index b3408559..dafe80a2 100644
--- a/src/features/profile/ProfileFeed.tsx
+++ b/src/features/profile/ProfileFeed.tsx
@@ -18,7 +18,8 @@ async function uploadFileToLibrary(file: File, onProgress: (pct: number) => void
await new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: videoApi.uploadEndpoint(),
- retryDelays: [0, 3000, 5000],
+ retryDelays: videoApi.uploadRetryDelays,
+ onShouldRetry: videoApi.shouldRetryUpload,
metadata: { videoid, filename: file.name, filetype: file.type },
headers: { Authorization: `Bearer ${uploadToken}` },
onProgress(bytesUploaded, bytesTotal) {
diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx
index a8d87c3d..01a39936 100644
--- a/src/features/tickets/TicketsPage.tsx
+++ b/src/features/tickets/TicketsPage.tsx
@@ -650,7 +650,7 @@ export const TicketsPage: React.FC = () => {
const userId = user?.id ?? null;
const { teams, selectedTeam, selectedTeamId, teamsReady } = useTeam();
const { isClockedIn, clockIn } = useClockToggle();
- const { navigate } = useRouter();
+ const { navigate, pathname } = useRouter();
const [tickets, setTickets] = useState([]);
const [ticketsLoading, setTicketsLoading] = useState(true);
@@ -728,8 +728,10 @@ export const TicketsPage: React.FC = () => {
if (selectedTeamId) setTeamFilter(selectedTeamId);
}, [selectedTeamId]);
- // Pull-to-refresh handler
- useRefresh(refetch);
+ // Pull-to-refresh handler — only while this page is the active route. It
+ // stays mounted (hidden) behind other routes, so registering unconditionally
+ // would hijack the visible page's refresh handler.
+ useRefresh(refetch, pathname === '/app/tickets');
// Stable key derived from sorted team IDs — the WS only reconnects when the
// actual set of teams changes, not on every new array reference from context.
diff --git a/src/lib/RefreshContext.tsx b/src/lib/RefreshContext.tsx
index 6c20a293..cf1fb9a9 100644
--- a/src/lib/RefreshContext.tsx
+++ b/src/lib/RefreshContext.tsx
@@ -6,12 +6,13 @@ import React, { createContext, useCallback, useContext, useEffect, useRef } from
type RefreshHandler = () => Promise | void;
interface RefreshContextValue {
- registerRefreshHandler: (handler: RefreshHandler) => void;
+ /** Register a refresh handler; returns an unregister function. */
+ registerRefreshHandler: (handler: RefreshHandler) => () => void;
triggerRefresh: () => Promise;
}
const RefreshContext = createContext({
- registerRefreshHandler: () => {},
+ registerRefreshHandler: () => () => {},
triggerRefresh: async () => {},
});
@@ -24,17 +25,23 @@ export const RefreshProvider: React.FC = ({
children,
globalRefreshHandlers = [],
}) => {
- const handlerRef = useRef(null);
+ // A Set (not a single slot) so multiple mounted pages can each register a
+ // handler. The tickets page stays mounted (hidden) behind other routes, so a
+ // single-slot design let it clobber the visible page's handler.
+ const handlersRef = useRef>(new Set());
const registerRefreshHandler = useCallback((handler: RefreshHandler) => {
- handlerRef.current = handler;
+ handlersRef.current.add(handler);
+ return () => {
+ handlersRef.current.delete(handler);
+ };
}, []);
const triggerRefresh = useCallback(async () => {
const promises: Promise[] = [];
- if (handlerRef.current) {
- const result = handlerRef.current();
+ for (const handler of handlersRef.current) {
+ const result = handler();
if (result instanceof Promise) {
promises.push(result);
}
@@ -57,15 +64,18 @@ export const RefreshProvider: React.FC = ({
);
};
-export const useRefresh = (handler: RefreshHandler): void => {
+/**
+ * Register a pull-to-refresh handler for the current page. Pass `enabled=false`
+ * to skip registration — used by always-mounted pages (e.g. the tickets page,
+ * kept alive behind other routes) so they only refresh while actually visible.
+ */
+export const useRefresh = (handler: RefreshHandler, enabled = true): void => {
const { registerRefreshHandler } = useContext(RefreshContext);
useEffect(() => {
- registerRefreshHandler(handler);
- return () => {
- registerRefreshHandler(() => {});
- };
- }, [handler, registerRefreshHandler]);
+ if (!enabled) return;
+ return registerRefreshHandler(handler);
+ }, [handler, enabled, registerRefreshHandler]);
};
export const useRefreshTrigger = (): (() => Promise) => {
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 37e7121f..ea617a7e 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -6,6 +6,7 @@
*/
// autoReconnectWs removed - no longer needed after migrating tickets to wormhole
import { CapacitorHttp } from '@capacitor/core';
+import type { DetailedError } from 'tus-js-client';
import { getDdpClient } from './ddp.js';
@@ -105,10 +106,52 @@ export interface PublicUser {
sharedTeams?: Array<{ id: string; name: string; isAdmin: boolean }>;
}
+/**
+ * Backend-served media paths. Anything under these is addressed by path only —
+ * whichever host is serving the backend right now owns it.
+ */
+const MEDIA_PATH_PREFIXES = ['/uploads/', '/pulsevault/'];
+
+/**
+ * Resolve a media URL against the *current* backend origin.
+ *
+ * Media URLs were historically persisted absolute (baked from ROOT_URL /
+ * VITE_TIMECORE_URL at upload time), which breaks the moment the backend moves
+ * — most visibly in dev, where the stack is served from the machine's LAN IP
+ * and every DHCP lease change orphans every previously-posted image and video.
+ *
+ * So the host in a stored media URL is treated as advisory: any URL whose path
+ * is backend-owned ({@link MEDIA_PATH_PREFIXES}) is re-based onto the origin
+ * this session actually talks to, whether it arrived relative or
+ * absolute-with-a-stale-host. URLs pointing anywhere else (a real CDN, an
+ * external link) are left alone.
+ *
+ * Re-based onto METEOR_API_BASE, not 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 get the absolute backend
+ * URL, as before.
+ */
+export function resolveMediaUrl(url: string | undefined | null): string {
+ if (!url) return '';
+ let path = url;
+ if (/^https?:\/\//i.test(url)) {
+ try {
+ const parsed = new URL(url);
+ if (!MEDIA_PATH_PREFIXES.some((p) => parsed.pathname.startsWith(p))) return url;
+ path = `${parsed.pathname}${parsed.search}`;
+ } catch {
+ return url;
+ }
+ }
+ return `${METEOR_API_BASE}${path.startsWith('/') ? '' : '/'}${path}`;
+}
+
function toAbsoluteUrl(url: string | null): string | null {
- if (!url || /^https?:\/\//i.test(url)) return url;
- const base = url.startsWith('/uploads/') ? METEOR_BASE_URL : TIMECORE_BASE_URL;
- return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
+ if (!url) return url;
+ const isMediaPath = MEDIA_PATH_PREFIXES.some((p) => url.startsWith(p));
+ if (isMediaPath || /^https?:\/\//i.test(url)) return resolveMediaUrl(url);
+ return `${TIMECORE_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
function withAbsoluteImage(user: PublicUser): PublicUser {
@@ -1003,6 +1046,15 @@ export const ticketApi = {
// ─── Huddle API ───────────────────────────────────────────────────────────────
+/** An image/video/file attached to a huddle post. */
+export interface HuddlePostAttachment {
+ mediaId: string;
+ type: 'image' | 'video' | 'file';
+ url: string;
+ thumbnailUrl?: string;
+ filename?: string;
+}
+
export interface HuddlePost {
id: string;
teamId: string;
@@ -1015,13 +1067,7 @@ export interface HuddlePost {
};
ticketId?: string;
ticketTitle?: string;
- attachments: Array<{
- mediaId: string;
- type: 'image' | 'video' | 'file';
- url: string;
- thumbnailUrl?: string;
- filename?: string;
- }>;
+ attachments: HuddlePostAttachment[];
likes: string[];
commentCount: number;
/** 'draft' = author-only, not in the feed, doesn't satisfy clock gates. */
@@ -1056,6 +1102,15 @@ export const huddleApi = {
(r) => r.posts,
),
+ /**
+ * Fetch all published huddle posts for a team over wormhole REST. Used to
+ * refresh the feed the moment a post is created, since the DDP socket can be
+ * down (the WebView drops it while backgrounded for a Pulse recording) and
+ * the live subscription would otherwise deliver the new post only later.
+ */
+ getPosts: (teamId: string) =>
+ wormholeCall<{ posts: HuddlePost[] }>('huddle.getPosts', { teamId }).then((r) => r.posts),
+
/** The caller's own post for a calendar date (YYYY-MM-DD) in a team, or null. */
getMyPostForDate: (teamId: string, postDate: string) =>
wormholeCall<{ post: HuddlePost | null }>('huddle.getMyPostForDate', {
@@ -1080,11 +1135,29 @@ export const huddleApi = {
getMyDrafts: (teamId: string) =>
wormholeCall<{ posts: HuddlePost[] }>('huddle.getMyDrafts', { teamId }).then((r) => r.posts),
+ /**
+ * Create a huddle post.
+ *
+ * Post authoring goes over wormhole REST (CapacitorHttp / fetch) rather than
+ * DDP: the WebView drops the DDP socket whenever the app is backgrounded —
+ * recording a Pulse video, for instance — and a DDP-only write then strands
+ * the post with no error until the socket reconnects. The feed itself still
+ * updates in realtime via the DDP subscription when one is live.
+ */
+ createPost: (params: {
+ teamId: string;
+ content: { text: string; mentions: string[] };
+ ticketId?: string;
+ attachments?: HuddlePostAttachment[];
+ postDate?: string;
+ draft?: boolean;
+ clockEventId?: string;
+ wrapUp?: boolean;
+ }) => wormholeCall<{ id: string }>('huddle.createPost', { ...params }),
+
/** Save a plan as an author-only draft (not in the feed, no gate effect). */
saveDraft: (teamId: string, content: { text: string; mentions: string[] }) =>
- getDdpClient().call('huddle.createPost', { teamId, content, draft: true }) as Promise<{
- id: string;
- }>,
+ wormholeCall<{ id: string }>('huddle.createPost', { teamId, content, draft: true }),
/** Publish a draft: optional content update + client-local postDate stamp;
* optionally link it to a clock session. */
@@ -1093,7 +1166,13 @@ export const huddleApi = {
postDate: string,
content?: { text: string; mentions: string[] },
clockEventId?: string,
- ) => getDdpClient().call('huddle.publishPost', { postId, postDate, content, clockEventId }),
+ ) =>
+ wormholeCall<{ id: string }>('huddle.publishPost', {
+ postId,
+ postDate,
+ content,
+ clockEventId,
+ }),
/** Update a huddle post. Pass wrapUp to stamp wrapUpAt (plan-first clock flow).
* Pass attachments/ticketId to edit them (omit to leave untouched). */
@@ -1102,16 +1181,10 @@ export const huddleApi = {
content: { text: string; mentions: string[] },
options?: {
wrapUp?: boolean;
- attachments?: Array<{
- mediaId: string;
- type: 'image' | 'video' | 'file';
- url: string;
- thumbnailUrl?: string;
- filename?: string;
- }>;
+ attachments?: HuddlePostAttachment[];
ticketId?: string | null;
},
- ) => getDdpClient().call('huddle.updatePost', { postId, content, ...options }),
+ ) => wormholeCall<{ id: string }>('huddle.updatePost', { postId, content, ...options }),
/** Delete a huddle post. */
deletePost: (postId: string) => getDdpClient().call('huddle.deletePost', { postId }),
@@ -1686,6 +1759,21 @@ export const videoApi = {
/** Shared authenticated TUS upload endpoint for ticket and media-library uploads. */
uploadEndpoint: () => `${METEOR_API_BASE}/pulsevault/upload`,
+ /**
+ * Shared TUS retry backoff for every PulseVault upload path. No leading `0`
+ * on purpose: an immediate retry can race the still-streaming PATCH (the
+ * proxy buffers and doesn't abort it), so the backend sees two concurrent
+ * PATCHes at the same offset and 409s the second one.
+ */
+ uploadRetryDelays: [3000, 5000, 10000] as number[],
+
+ /**
+ * Don't retry a `409 Upload-Offset conflict`: it means a concurrent/duplicate
+ * PATCH already advanced the offset, so retrying only conflicts again. Every
+ * upload path shares this so ticket and huddle behave identically.
+ */
+ shouldRetryUpload: (err: DetailedError): boolean => err.originalResponse?.getStatus() !== 409,
+
/** Reserve a videoid for a ticket upload before starting TUS.
* Pass `existingVideoid` when resuming a recording session so the backend
* re-registers the same id instead of creating a new one.
@@ -1729,21 +1817,45 @@ function withAbsoluteMediaItem(item: MediaItem): MediaItem {
}
export const mediaApi = {
- uploadImage: async (file: File): Promise => {
+ /**
+ * Upload an image or document to the media library.
+ *
+ * Uses XMLHttpRequest rather than `fetch` because only XHR exposes
+ * upload-side byte progress, which the composer needs to render one progress
+ * bar across image, document, and (TUS) video uploads alike.
+ */
+ uploadImage: async (file: File, onProgress?: (fraction: number) => void): Promise => {
const form = new FormData();
form.append('file', file, file.name || 'image');
const token = await getAccessToken();
- const res = await fetch(`${METEOR_API_BASE}/api/media/upload`, {
- method: 'POST',
- headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
- body: form,
- });
- if (!res.ok) {
- const body = (await res.json().catch(() => ({}))) as Record;
- throw new ApiError((body.error as string) ?? `HTTP ${res.status}`, res.status);
+
+ const { status, body } = await new Promise<{ status: number; body: string }>(
+ (resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', `${METEOR_API_BASE}/api/media/upload`);
+ if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable && e.total > 0) onProgress?.(e.loaded / e.total);
+ };
+ xhr.onload = () => resolve({ status: xhr.status, body: xhr.responseText });
+ xhr.onerror = () => reject(new ApiError('Upload failed', 0));
+ xhr.onabort = () => reject(new ApiError('Upload cancelled', 0));
+ xhr.send(form);
+ },
+ );
+
+ const parsed = (() => {
+ try {
+ return JSON.parse(body) as { item?: MediaItem; error?: string };
+ } catch {
+ return {} as { item?: MediaItem; error?: string };
+ }
+ })();
+
+ if (status < 200 || status >= 300 || !parsed.item) {
+ throw new ApiError(parsed.error ?? `HTTP ${status}`, status);
}
- const data = (await res.json()) as { item: MediaItem };
- return withAbsoluteMediaItem(data.item);
+ return withAbsoluteMediaItem(parsed.item);
},
list: () =>
diff --git a/src/main.tsx b/src/main.tsx
index 9aadc16e..9fa4a413 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,10 +6,30 @@ import { CapacitorUpdater } from '@capgo/capacitor-updater';
// Confirms the OTA bundle booted — runs first, before any other bootstrap or
// network/chunk fetch. If the native layer doesn't hear this within
// appReadyTimeout it rolls back to the previous bundle.
+//
+// For local builds (VITE_LOCAL_BUILD=true): evict any cached OTA bundle so the
+// freshly-built native bundle is always used instead.
if (Capacitor.isNativePlatform()) {
- CapacitorUpdater.notifyAppReady().catch((err) =>
- console.error('[TimeHuddle] notifyAppReady failed:', err),
- );
+ if ((import.meta as { env?: Record }).env?.VITE_LOCAL_BUILD === 'true') {
+ CapacitorUpdater.current()
+ .then(({ bundle }) => {
+ if (bundle.id !== 'builtin') {
+ // Reload to the built-in bundle; the WebView restarts and won't reach here.
+ CapacitorUpdater.reset({ toLastSuccessful: false }).catch(() => {});
+ } else {
+ CapacitorUpdater.notifyAppReady().catch((err) =>
+ console.error('[TimeHuddle] notifyAppReady failed:', err),
+ );
+ }
+ })
+ .catch(() => {
+ CapacitorUpdater.notifyAppReady().catch(() => {});
+ });
+ } else {
+ CapacitorUpdater.notifyAppReady().catch((err) =>
+ console.error('[TimeHuddle] notifyAppReady failed:', err),
+ );
+ }
}
// ─── Eager theme + brand bootstrap ───────────────────────────────────────────
diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx
index 80c5c127..f9ad4a17 100644
--- a/src/pages/Huddle.tsx
+++ b/src/pages/Huddle.tsx
@@ -12,7 +12,7 @@ import {
createImagePlugin,
createMermaidPlugin,
} from '@mieweb/ui/components/SuperChat/plugins';
-import { useMemo, useState, useEffect } from 'react';
+import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { HuddleComposer } from '../features/huddle/HuddleComposer';
import { DraftsPanel } from '../features/huddle/DraftsPanel';
import { PostCard } from '../features/huddle/PostCard';
@@ -26,6 +26,7 @@ import { useSession } from '@lib/useSession';
import { useTeam } from '@lib/TeamContext';
import { teamApi, huddleApi, type HuddlePost, type Team } from '@lib/api';
import { getDdpClient } from '@lib/ddp';
+import { useRefresh } from '@lib/RefreshContext';
import { toDateString } from '@lib/timeUtils';
export default function Huddle() {
@@ -102,6 +103,49 @@ export default function Huddle() {
loadTeam();
}, [selectedTeamId]);
+ // Locally-created posts the live DDP cache hasn't delivered yet, overlaid
+ // onto the feed so a new post shows instantly. Each entry is dropped as soon
+ // as the subscription catches up (see syncPosts).
+ const pendingPostsRef = useRef
)}
- {/* Composer stays put while the feed below it scrolls */}
+ {/* Composer stays put while the feed below it scrolls.
+ On a short viewport the expanded composer is taller than the space
+ between the header and the fixed bottom nav, so it must be able to
+ shrink and scroll its own overflow — otherwise its lower half (the
+ attach buttons, Cancel and Post) is clipped under the nav and
+ unreachable. min-h-0 is what lets a flex child shrink below its
+ content height. */}
{selectedTeamId && feedTab === 'feed' && (
-
+
so it isn't re-injected per composer instance. */
+@keyframes huddle-progress {
+ from {
+ width: 0%;
+ }
+ to {
+ width: 83%;
+ }
+}
diff --git a/src/ui/AnchoredMenu.tsx b/src/ui/AnchoredMenu.tsx
new file mode 100644
index 00000000..ac059fdd
--- /dev/null
+++ b/src/ui/AnchoredMenu.tsx
@@ -0,0 +1,142 @@
+/**
+ * AnchoredMenu — a dropdown portaled to and positioned with `fixed`
+ * coordinates read from its trigger's rect.
+ *
+ * An anchored menu inside a scroll container cannot use `position: absolute`:
+ * once one overflow axis is non-`visible` the CSS overflow spec forces the
+ * other to clip too, so a menu opening below its trigger is silently hidden.
+ * That is exactly what swallowed the Ticket / @Mention menus on the Huddle
+ * feed, whose composer lives in an `overflow-y-auto` wrapper — the same
+ * failure the ticket row options menu hits. Portaling out of the container is
+ * the only reliable fix.
+ *
+ * The menu also flips above the trigger when there isn't room below, and
+ * exposes the space it has as `max-height` so long lists scroll inside it
+ * instead of running off-screen.
+ */
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import type { CSSProperties, ReactNode, RefObject } from 'react';
+import { createPortal } from 'react-dom';
+
+/** Breathing room kept between the menu and the trigger / viewport edges. */
+const GUTTER = 8;
+/** Below this much space under the trigger, the menu flips above it. */
+const FLIP_THRESHOLD = 200;
+
+interface AnchoredMenuProps {
+ open: boolean;
+ /** Called on Escape or a pointer press outside both menu and trigger. */
+ onClose: () => void;
+ /** The element the menu aligns to. */
+ anchorRef: RefObject;
+ /** Preferred width in px, clamped to the viewport. */
+ width: number;
+ /** Accessible name for the menu. */
+ label: string;
+ testId?: string;
+ children: ReactNode;
+}
+
+export function AnchoredMenu({
+ open,
+ onClose,
+ anchorRef,
+ width,
+ label,
+ testId,
+ children,
+}: AnchoredMenuProps) {
+ const menuRef = useRef(null);
+ // null until measured, so the menu never paints one frame at the wrong spot.
+ const [style, setStyle] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!open) {
+ setStyle(null);
+ return;
+ }
+
+ const reposition = () => {
+ const anchor = anchorRef.current;
+ if (!anchor) return;
+ const rect = anchor.getBoundingClientRect();
+ const menuWidth = Math.min(width, window.innerWidth - GUTTER * 2);
+ const left = Math.min(Math.max(GUTTER, rect.left), window.innerWidth - GUTTER - menuWidth);
+ const spaceBelow = window.innerHeight - rect.bottom - GUTTER * 2;
+ const spaceAbove = rect.top - GUTTER * 2;
+ const flipUp = spaceBelow < FLIP_THRESHOLD && spaceAbove > spaceBelow;
+
+ setStyle({
+ position: 'fixed',
+ left,
+ width: menuWidth,
+ // Exactly the space available on the chosen side, with no floor: the
+ // flip above already picks the roomier side, so clamping up to a
+ // minimum here would only push the menu back off-screen on a short
+ // viewport. A cramped-but-scrollable menu beats a clipped one.
+ maxHeight: Math.max(0, flipUp ? spaceAbove : spaceBelow),
+ ...(flipUp
+ ? { bottom: window.innerHeight - rect.top + GUTTER }
+ : { top: rect.bottom + GUTTER }),
+ });
+ };
+
+ // Scroll fires far faster than the screen repaints, and each run costs a
+ // layout-forcing getBoundingClientRect plus a setState — coalesce to one
+ // per frame so dragging a long list stays smooth.
+ let frame = 0;
+ const scheduleReposition = () => {
+ if (frame) return;
+ frame = requestAnimationFrame(() => {
+ frame = 0;
+ reposition();
+ });
+ };
+
+ reposition();
+ window.addEventListener('resize', scheduleReposition);
+ // Capture phase, so scrolling any ancestor container re-anchors the menu.
+ window.addEventListener('scroll', scheduleReposition, true);
+ return () => {
+ if (frame) cancelAnimationFrame(frame);
+ window.removeEventListener('resize', scheduleReposition);
+ window.removeEventListener('scroll', scheduleReposition, true);
+ };
+ }, [open, width, anchorRef]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target as Node;
+ // The trigger owns its own toggle — closing here would fight it.
+ if (anchorRef.current?.contains(target)) return;
+ if (menuRef.current?.contains(target)) return;
+ onClose();
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') onClose();
+ };
+ document.addEventListener('mousedown', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, onClose, anchorRef]);
+
+ if (!open || !style || typeof document === 'undefined') return null;
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+}
diff --git a/src/ui/PullToRefresh.tsx b/src/ui/PullToRefresh.tsx
index 847b93e1..842141ee 100644
--- a/src/ui/PullToRefresh.tsx
+++ b/src/ui/PullToRefresh.tsx
@@ -222,7 +222,11 @@ export const PullToRefresh: React.FC = ({ children }) => {
// Flex-column layout: spacer expands to show the indicator, content fills
// the rest. Using height instead of CSS transform avoids creating a new
// stacking context, which would break position:fixed modal backdrops.
-
+
{/* Pull indicator — spacer height grows to reveal spinner */}
{
* Force the given page onto the shared "Test Team Alpha" (TEST01) team by
* writing every `app:selectedTeamId*` localStorage key and reloading.
*
+ * Retries, because writing the key is not by itself enough: TeamContext
+ * re-points the selection at `scopedTeams[0]` (the user's Personal team)
+ * whenever the stored id isn't in the team list *yet* — so a selection made
+ * while the team list is still loading gets silently reverted, and the test
+ * then runs against a one-member personal feed. Reading the key back after the
+ * app has settled is the only way to know the selection actually took.
+ *
* Requires the page to already be on an in-app route so localStorage is
* writable for the app origin.
*/
@@ -40,13 +47,33 @@ export async function selectSharedTestTeam(page: Page): Promise
{
if (!teamId) {
throw new Error('Shared seed team TEST01 not found — did global-setup run?');
}
- await page.evaluate((id) => {
- Object.keys(localStorage)
- .filter((k) => k.startsWith('app:selectedTeamId'))
- .forEach((k) => localStorage.setItem(k, id));
- localStorage.setItem('app:selectedTeamId', id);
- }, teamId);
- await page.reload();
- await page.waitForLoadState('networkidle');
- return teamId;
+
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await page.evaluate((id) => {
+ Object.keys(localStorage)
+ .filter((k) => k.startsWith('app:selectedTeamId'))
+ .forEach((k) => localStorage.setItem(k, id));
+ localStorage.setItem('app:selectedTeamId', id);
+ }, teamId);
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+
+ const settled = await page
+ .waitForFunction(
+ (id) =>
+ Object.keys(localStorage)
+ .filter((k) => k.startsWith('app:selectedTeamId'))
+ .every((k) => localStorage.getItem(k) === id),
+ teamId,
+ { timeout: 10000 },
+ )
+ .then(() => true)
+ .catch(() => false);
+
+ if (settled) return teamId;
+ }
+
+ throw new Error(
+ `Could not switch to shared team ${teamId} — TeamContext keeps reverting to the personal team.`,
+ );
}
diff --git a/tests/e2e/fixtures/test-doc.txt b/tests/e2e/fixtures/test-doc.txt
new file mode 100644
index 00000000..00d7540c
--- /dev/null
+++ b/tests/e2e/fixtures/test-doc.txt
@@ -0,0 +1 @@
+TimeHuddle e2e doc attachment fixture
diff --git a/tests/e2e/fixtures/test-image.png b/tests/e2e/fixtures/test-image.png
new file mode 100644
index 00000000..f37764b1
Binary files /dev/null and b/tests/e2e/fixtures/test-image.png differ
diff --git a/tests/e2e/huddle/composer-actions.spec.ts b/tests/e2e/huddle/composer-actions.spec.ts
new file mode 100644
index 00000000..72160985
--- /dev/null
+++ b/tests/e2e/huddle/composer-actions.spec.ts
@@ -0,0 +1,236 @@
+/**
+ * Huddle Composer — every attach action, alone and combined.
+ *
+ * One test per composer action (photo, doc, mention, ticket) plus a combined
+ * post carrying all of them at once, because the actions share state in
+ * HuddleComposer (attachments, mentions, selectedTicketId, ticketVideos) and
+ * regressions have historically shown up only when several are set together.
+ *
+ * Video gets its own file (pulsevault-video.spec.ts) — it goes through the TUS
+ * upload path rather than the multipart media endpoint — but the combined post
+ * here includes one, since "photo + video + mention in one post" is exactly the
+ * case that exercises every branch of `toPostAttachment` at once.
+ *
+ * Assertions go against the real backend: an attached image must come back as
+ * an
whose src actually resolves, not merely as "an img element exists".
+ */
+import { expect, test, type Page } from '@playwright/test';
+import { TEST_USERS, loginAs } from '../fixtures/users';
+import { selectSharedTestTeam } from '../fixtures/team';
+import { createTicket, deleteTicket } from '../tickets/helpers';
+import {
+ FIXTURE,
+ attachFile,
+ attachTicket,
+ attachmentChipCount,
+ composerEditor,
+ mentionMember,
+ openComposer,
+ postContainer,
+ submitPost,
+ switchToCardView,
+} from './helpers';
+
+/** Fetches an attachment URL from inside the page and reports its status. */
+async function fetchStatus(page: Page, url: string): Promise {
+ return page.evaluate(async (u) => {
+ const res = await fetch(u, { method: 'GET' });
+ return res.status;
+ }, url);
+}
+
+test.describe('Huddle composer — individual actions', () => {
+ test.setTimeout(120000);
+
+ test.beforeEach(async ({ page }) => {
+ await loginAs(page, TEST_USERS.owner1);
+ await selectSharedTestTeam(page);
+ await openComposer(page);
+ });
+
+ test('posts text only', async ({ page }) => {
+ const postText = `Text only ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+ await submitPost(page);
+
+ await switchToCardView(page);
+ await expect(postContainer(page, postText)).toBeVisible({ timeout: 15000 });
+ });
+
+ test('posts a photo that is served back by the backend', async ({ page }) => {
+ const postText = `Photo post ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+ await attachFile(page, 'image');
+ await submitPost(page);
+
+ await switchToCardView(page);
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 15000 });
+
+ const img = post.locator('img[src*="/uploads/media/"]');
+ await expect(img).toBeVisible({ timeout: 10000 });
+
+ // naturalWidth is the real proof: a broken src still renders an
, but
+ // only a decoded image has non-zero intrinsic dimensions. (Deliberately not
+ // a fetch() of the same URL — the browser has already cached the
's
+ // no-cors response, which a subsequent cross-origin fetch cannot reuse.)
+ await expect
+ .poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth), { timeout: 10000 })
+ .toBeGreaterThan(0);
+ });
+
+ test('posts a document as a downloadable link', async ({ page }) => {
+ const postText = `Doc post ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+ await attachFile(page, 'doc');
+ await submitPost(page);
+
+ await switchToCardView(page);
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 15000 });
+
+ const link = post.locator('a[download][href*="/uploads/media/"]');
+ await expect(link).toBeVisible({ timeout: 10000 });
+ expect(await fetchStatus(page, (await link.getAttribute('href'))!)).toBe(200);
+ });
+
+ test('posts with an @mention of a teammate', async ({ page }) => {
+ const postText = `Mention post ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+ await mentionMember(page, TEST_USERS.member1.name);
+ await submitPost(page);
+
+ await switchToCardView(page);
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 15000 });
+ await expect(post).toContainText(`@${TEST_USERS.member1.name}`);
+ });
+
+ test('a removed attachment is left out of the post', async ({ page }) => {
+ const postText = `Removed attachment ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+
+ // Attach two, drop one — the survivor must be the one that posts. Removing
+ // the *first* of two on purpose: dropping the last chip can pass against an
+ // off-by-one in the filter, dropping a middle/leading one cannot.
+ await attachFile(page, 'image');
+ await attachFile(page, 'doc');
+ await page.locator('button[aria-label^="Remove attachment"]').first().click();
+ await expect.poll(() => attachmentChipCount(page)).toBe(1);
+
+ await submitPost(page);
+ await switchToCardView(page);
+
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 15000 });
+ await expect(post.locator('a[download][href*="/uploads/media/"]')).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(post.locator('img[src*="/uploads/media/"]')).toHaveCount(0);
+ });
+
+ test('posts with a ticket attached', async ({ page }) => {
+ const ticketTitle = `Composer Ticket ${Date.now()}`;
+ await createTicket(page, ticketTitle);
+ await openComposer(page);
+
+ const postText = `Ticket post ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+ await attachTicket(page, ticketTitle);
+ await submitPost(page);
+
+ await switchToCardView(page);
+ const post = postContainer(page, ticketTitle);
+ await expect(post).toBeVisible({ timeout: 15000 });
+ await expect(post).toContainText(postText);
+
+ await deleteTicket(page, ticketTitle);
+ });
+});
+
+test.describe('Huddle composer — combined actions', () => {
+ test.setTimeout(180000);
+
+ test('posts photo + doc + video + mention + ticket in one post', async ({ page }) => {
+ await loginAs(page, TEST_USERS.owner1);
+ await selectSharedTestTeam(page);
+
+ const ticketTitle = `Combo Ticket ${Date.now()}`;
+ await createTicket(page, ticketTitle);
+ await openComposer(page);
+
+ const postText = `Everything at once ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+
+ await attachFile(page, 'image');
+ await attachFile(page, 'doc');
+ await attachFile(page, 'video');
+ await mentionMember(page, TEST_USERS.member1.name);
+ await attachTicket(page, ticketTitle);
+
+ // All three uploads survived each other — an upload starting while another
+ // is settling used to clobber the earlier chip.
+ await expect(page.locator('button[aria-label^="Remove attachment"]')).toHaveCount(3);
+
+ await submitPost(page);
+
+ await switchToCardView(page);
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 20000 });
+
+ await expect(post.locator('img[src*="/uploads/media/"]')).toBeVisible({ timeout: 15000 });
+ await expect(post.locator('a[download][href*="/uploads/media/"]')).toBeVisible();
+ await expect(post.locator('video[src*="/pulsevault/artifacts/"]')).toBeVisible();
+ await expect(post).toContainText(`@${TEST_USERS.member1.name}`);
+ await expect(post).toContainText(ticketTitle);
+
+ await deleteTicket(page, ticketTitle);
+ });
+});
+
+test.describe('Huddle composer — upload progress', () => {
+ test.setTimeout(120000);
+
+ test.beforeEach(async ({ page }) => {
+ await loginAs(page, TEST_USERS.owner1);
+ await selectSharedTestTeam(page);
+ await openComposer(page);
+ });
+
+ test('shows a progress bar while a video uploads and blocks posting until it lands', async ({
+ page,
+ }) => {
+ await composerEditor(page).fill(`Upload progress ${Date.now()}`);
+
+ const progressBar = page.locator('[data-testid="post-progress-bar"]');
+ const progressVisible = progressBar.waitFor({ state: 'visible', timeout: 20000 });
+
+ await page.locator('input[type="file"][accept="video/*"]').setInputFiles(FIXTURE.video);
+ await progressVisible;
+
+ // Upload phase is determinate and labelled distinctly from the post phase.
+ await expect(progressBar).toHaveAttribute('aria-label', 'Uploading attachment');
+ await expect(progressBar).toHaveAttribute('aria-valuenow', /\d+/);
+
+ // Submitting mid-upload would strand the half-uploaded attachment.
+ await expect(page.getByRole('button', { name: 'Post', exact: true })).toBeDisabled();
+
+ await expect(page.locator('button[aria-label^="Remove attachment"]')).toHaveCount(1, {
+ timeout: 60000,
+ });
+ await expect(page.getByRole('button', { name: 'Post', exact: true })).toBeEnabled();
+ });
+
+ test('the Video button reports its own upload state', async ({ page }) => {
+ await composerEditor(page).fill(`Video button state ${Date.now()}`);
+ await page.locator('input[type="file"][accept="video/*"]').setInputFiles(FIXTURE.video);
+
+ // The pressed button becomes the busy one, so it's clear *which* attachment
+ // is in flight when several kinds are available.
+ const busy = page.locator('button[aria-busy="true"]');
+ await expect(busy).toHaveText(/Uploading/, { timeout: 20000 });
+ await expect(page.locator('button[aria-label^="Remove attachment"]')).toHaveCount(1, {
+ timeout: 60000,
+ });
+ });
+});
diff --git a/tests/e2e/huddle/composer-paste.spec.ts b/tests/e2e/huddle/composer-paste.spec.ts
new file mode 100644
index 00000000..f92ab007
--- /dev/null
+++ b/tests/e2e/huddle/composer-paste.spec.ts
@@ -0,0 +1,140 @@
+/**
+ * Huddle Composer — pasting a screenshot.
+ *
+ * Kerebron's own paste handler embeds a pasted image inline as a base64 `data:`
+ * URL, which bloats the post document by hundreds of KB and never puts the file
+ * in the media store. The composer intercepts the paste first and uploads it
+ * like any other attachment (MarkdownEditor's capture-phase listener ->
+ * useAttachmentUpload).
+ *
+ * The load-bearing assertion in every test here is the *negative* one: no
+ * `data:` URL survives anywhere. A paste that silently fell back to Kerebron's
+ * handler would still produce a visible image in the feed, so asserting only
+ * "an image is shown" would pass against the exact bug this replaced.
+ */
+import { expect, test } from '@playwright/test';
+import { TEST_USERS, loginAs } from '../fixtures/users';
+import { selectSharedTestTeam } from '../fixtures/team';
+import {
+ FIXTURE,
+ attachmentChipCount,
+ composerEditor,
+ openComposer,
+ pasteFiles,
+ pasteText,
+ postContainer,
+ submitPost,
+ switchToCardView,
+} from './helpers';
+
+const SCREENSHOT = { fixture: FIXTURE.image, name: 'screenshot.png', type: 'image/png' };
+
+test.describe('Huddle composer — screenshot paste', () => {
+ test.setTimeout(120000);
+
+ test.beforeEach(async ({ page }) => {
+ await loginAs(page, TEST_USERS.owner1);
+ await selectSharedTestTeam(page);
+ await openComposer(page);
+ });
+
+ test('pasting a screenshot uploads it as an attachment instead of inlining base64', async ({
+ page,
+ }) => {
+ const postText = `Pasted screenshot ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+
+ await pasteFiles(page, [SCREENSHOT]);
+
+ // It became a real attachment chip…
+ await expect.poll(() => attachmentChipCount(page), { timeout: 30000 }).toBe(1);
+ // …and no image node was inserted into the document. Checking for an
+ // rather than for "data:" in the text: an embedded image is a ProseMirror
+ // node, so its base64 src lives in an attribute that textContent never
+ // exposes — a text assertion would pass even when the paste was inlined.
+ await expect(composerEditor(page).locator('img')).toHaveCount(0);
+ await expect(composerEditor(page)).toContainText(postText);
+
+ await submitPost(page);
+ await switchToCardView(page);
+
+ const post = postContainer(page, postText);
+ await expect(post).toBeVisible({ timeout: 20000 });
+
+ // Served from the media store, not embedded in the document.
+ const img = post.locator('img[src*="/uploads/media/"]');
+ await expect(img).toBeVisible({ timeout: 15000 });
+ await expect(post.locator('img[src^="data:"]')).toHaveCount(0);
+
+ // naturalWidth is the real proof the src resolves: a broken src still
+ // renders an
, but only a decoded image has intrinsic dimensions.
+ await expect
+ .poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth), { timeout: 15000 })
+ .toBeGreaterThan(0);
+ });
+
+ test('pasting a screenshot into an empty composer posts image-only', async ({ page }) => {
+ await pasteFiles(page, [SCREENSHOT]);
+ await expect.poll(() => attachmentChipCount(page), { timeout: 30000 }).toBe(1);
+
+ // An attachment alone is enough content to post — the button must enable
+ // without any text, and only once the upload has actually landed.
+ const postButton = page.getByRole('button', { name: 'Post', exact: true });
+ await expect(postButton).toBeEnabled();
+ await submitPost(page);
+
+ await switchToCardView(page);
+ // Scoped to the newest card, not "any card in the feed": the suite is
+ // serial against a shared team, so earlier tests have already left images
+ // in this feed and an unscoped match would pass without posting anything.
+ // The feed sorts createdAt descending, so the first card is this post.
+ const newest = page.locator('[data-testid="post-card"]').first();
+ await expect(newest.locator('img[src*="/uploads/media/"]')).toBeVisible({ timeout: 20000 });
+ });
+
+ test('pasting several images at once uploads every one', async ({ page }) => {
+ const postText = `Multi paste ${Date.now()}`;
+ await composerEditor(page).fill(postText);
+
+ await pasteFiles(page, [
+ { ...SCREENSHOT, name: 'shot-one.png' },
+ { ...SCREENSHOT, name: 'shot-two.png' },
+ ]);
+
+ // Uploads run sequentially through one shared hook — the second must not
+ // clobber the first's chip.
+ await expect.poll(() => attachmentChipCount(page), { timeout: 45000 }).toBe(2);
+ await expect(composerEditor(page).locator('img')).toHaveCount(0);
+ });
+
+ test('shows upload progress and blocks posting while a pasted image is in flight', async ({
+ page,
+ }) => {
+ await composerEditor(page).fill(`Paste progress ${Date.now()}`);
+
+ const progressBar = page.locator('[data-testid="post-progress-bar"]');
+ const progressVisible = progressBar.waitFor({ state: 'visible', timeout: 15000 });
+
+ await pasteFiles(page, [SCREENSHOT]);
+ await progressVisible;
+
+ // Same bar and labelling as a picker-driven upload — a paste is not a
+ // second, quieter upload path.
+ await expect(progressBar).toHaveAttribute('aria-label', 'Uploading attachment');
+ await expect(page.getByRole('button', { name: 'Post', exact: true })).toBeDisabled();
+
+ await expect.poll(() => attachmentChipCount(page), { timeout: 30000 }).toBe(1);
+ await expect(page.getByRole('button', { name: 'Post', exact: true })).toBeEnabled();
+ });
+
+ test('pasting plain text still goes into the editor', async ({ page }) => {
+ // Regression guard on the interception: it must return early for anything
+ // that carries no image files, or ordinary copy-paste breaks entirely.
+ const pasted = `Pasted plain text ${Date.now()}`;
+ await composerEditor(page).click();
+ await pasteText(page, pasted);
+
+ await expect(composerEditor(page)).toContainText(pasted, { timeout: 10000 });
+ expect(await attachmentChipCount(page)).toBe(0);
+ });
+});
diff --git a/tests/e2e/huddle/composer-responsive.spec.ts b/tests/e2e/huddle/composer-responsive.spec.ts
new file mode 100644
index 00000000..ec687e8e
--- /dev/null
+++ b/tests/e2e/huddle/composer-responsive.spec.ts
@@ -0,0 +1,102 @@
+/**
+ * Huddle Composer — responsiveness.
+ *
+ * The composer sits in a bounded flex column between the page header and the
+ * fixed mobile bottom nav. Expanded, it is taller than that gap on a short
+ * viewport, so unless it can shrink and scroll its own overflow the lower half
+ * — Pulse, Ticket, @Mention, Cancel and Post — is clipped underneath the nav
+ * and unreachable. That regression is invisible on a desktop viewport and on a
+ * tall phone, so it is pinned here at the narrowest size the app supports.
+ */
+import { expect, test, type Page } from '@playwright/test';
+import { TEST_USERS, loginAs } from '../fixtures/users';
+import { selectSharedTestTeam } from '../fixtures/team';
+import { composerEditor, openComposer } from './helpers';
+
+const ACTION_BUTTONS = ['Photo', 'Video', 'Doc', 'Ticket', '@Mention', 'Post'];
+
+/**
+ * Scrolls a control into view within the composer and reports whether it is
+ * actually usable, with enough detail to tell *how* it failed.
+ */
+async function reachability(page: Page, name: string): Promise {
+ const button = page.getByRole('button', { name, exact: true });
+ const count = await button.count();
+ if (count !== 1) return `matched ${count} elements`;
+
+ await button.scrollIntoViewIfNeeded().catch(() => {});
+ if (!(await button.isVisible().catch(() => false))) return 'not visible';
+
+ const box = await button.boundingBox();
+ if (!box) return 'no bounding box';
+
+ // A control hidden behind the fixed bottom nav is "visible" to the DOM but
+ // cannot be clicked, so check the geometry too. The nav stays in the DOM at
+ // desktop widths (it is `md:hidden`, i.e. display:none) where it reports an
+ // all-zero rect — treat that as "no nav" rather than "nothing below y=0".
+ const navTop = await page.evaluate(() => {
+ const rect = document.querySelector('nav.bottom-nav')?.getBoundingClientRect();
+ return rect && rect.height > 0 ? rect.top : Number.POSITIVE_INFINITY;
+ });
+ const limit = Math.min(navTop, page.viewportSize()!.height);
+ if (box.y < 0) return `above the viewport (y=${Math.round(box.y)})`;
+ if (box.y + box.height > limit) {
+ return `below the usable area (bottom=${Math.round(box.y + box.height)}, limit=${Math.round(limit)})`;
+ }
+ return 'reachable';
+}
+
+test.describe('Huddle composer — responsive layout', () => {
+ test.setTimeout(120000);
+
+ test.beforeEach(async ({ page }) => {
+ await loginAs(page, TEST_USERS.owner1);
+ await selectSharedTestTeam(page);
+ });
+
+ for (const viewport of [
+ { label: 'small phone', width: 320, height: 568 },
+ { label: 'phone', width: 390, height: 844 },
+ { label: 'tablet', width: 768, height: 1024 },
+ { label: 'desktop', width: 1280, height: 900 },
+ ]) {
+ test(`every composer control is reachable at ${viewport.label} (${viewport.width}x${viewport.height})`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await openComposer(page);
+ await composerEditor(page).fill('Responsive check');
+
+ for (const name of ACTION_BUTTONS) {
+ expect(
+ await reachability(page, name),
+ `"${name}" at ${viewport.width}x${viewport.height}`,
+ ).toBe('reachable');
+ }
+
+ // Nothing may force the page itself to scroll sideways.
+ const overflowsHorizontally = await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ );
+ expect(overflowsHorizontally).toBe(false);
+ });
+ }
+
+ test('the composer scrolls its own overflow instead of clipping it', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 568 });
+ await openComposer(page);
+ await composerEditor(page).fill('Overflow check');
+
+ const composer = page.locator('.huddle-composer');
+ const { scrollHeight, clientHeight, canScroll } = await composer.evaluate((el) => ({
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ canScroll: getComputedStyle(el).overflowY === 'auto',
+ }));
+
+ expect(canScroll).toBe(true);
+ // The composer is genuinely taller than its slot here — that is the whole
+ // point of the test; it must absorb the excess rather than overflow it.
+ expect(scrollHeight).toBeGreaterThan(clientHeight);
+ });
+});
diff --git a/tests/e2e/huddle/helpers.ts b/tests/e2e/huddle/helpers.ts
new file mode 100644
index 00000000..ad64b04c
--- /dev/null
+++ b/tests/e2e/huddle/helpers.ts
@@ -0,0 +1,167 @@
+/**
+ * Shared helpers for the Huddle composer/feed specs.
+ *
+ * Every huddle spec needs the same four things — open the composer, drive one
+ * of its attach actions, switch the feed to the view that renders attachments
+ * inline, and find a post by its unique body text — so they live here rather
+ * than being re-derived (slightly differently) in each file.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { expect, type Page } from '@playwright/test';
+
+const FIXTURES_DIR = path.join(__dirname, '../fixtures');
+
+/** Real fixture files, uploaded through the actual backend endpoints. */
+export const FIXTURE = {
+ image: path.join(FIXTURES_DIR, 'test-image.png'),
+ doc: path.join(FIXTURES_DIR, 'test-doc.txt'),
+ video: path.join(FIXTURES_DIR, 'test-video.mp4'),
+};
+
+/**
+ * The composer's editable surface. Kerebron's RichEditor renders a ProseMirror
+ * contenteditable rather than a