Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 46 additions & 17 deletions PulsarApp/app/(tabs)/figma.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Card from '@/components/Card';
import Point from '@/components/Point';
import SvgIcon from '@/components/SvgIcon';
import ConnectionList from '@/components/home/ConnectionList';
import NowPlayingToast from '@/components/NowPlayingToast';
import { Margins } from '@/constants/theme';

const defaultEdges = {
Expand Down Expand Up @@ -60,10 +61,15 @@ export default function FigmaScreen() {
const params = useLocalSearchParams<{ token?: string }>();
const token = typeof params.token === 'string' ? params.token : '';

if (token) {
return <FigmaPreviewWebView token={token} />;
}
return <FigmaExplainer />;
// The "preset received" banner overlays both modes (live preview + explainer),
// so a preset played from the plugin is visible while the user sits on the
// Figma screen too - not only on the home tab.
return (
<View style={styles.screen}>
{token ? <FigmaPreviewWebView token={token} /> : <FigmaExplainer />}
<NowPlayingToast />
</View>
);
}

function FigmaPreviewWebView({ token }: { token: string }) {
Expand All @@ -80,6 +86,15 @@ function FigmaPreviewWebView({ token }: { token: string }) {
const router = useRouter();
const { lastPreviewUpdate } = useConnections();

// Manage the loading overlay ourselves instead of the WebView's built-in
// startInLoadingState. That built-in overlay only clears on the *initial*
// load, so when the preview fails and the WebView instead lands on Figma's
// login page, the spinner stayed up and swallowed taps on the login button.
// Here we drop it as soon as any content finishes loading (onLoadEnd fires on
// success and failure), and the overlay is pointerEvents="none" so it can
// never trap a tap even if it lingers.
const [loading, setLoading] = useState(true);

// Leave the active preview and return to the Figma list/explainer by clearing
// the route's token (FigmaScreen renders the explainer when it's empty).
const closePreview = useCallback(() => {
Expand Down Expand Up @@ -153,21 +168,23 @@ function FigmaPreviewWebView({ token }: { token: string }) {
</TouchableOpacity>
</View>
)}
<WebView
ref={webRef}
source={{ uri: previewUrl }}
originWhitelist={['*']}
javaScriptEnabled
domStorageEnabled
onMessage={onMessage}
startInLoadingState
renderLoading={() => (
<View style={styles.loader}>
<View style={styles.webContainer}>
<WebView
ref={webRef}
source={{ uri: previewUrl }}
originWhitelist={['*']}
javaScriptEnabled
domStorageEnabled
onMessage={onMessage}
onLoadEnd={() => setLoading(false)}
style={styles.webview}
/>
{loading && (
<View style={styles.loader} pointerEvents="none">
<ActivityIndicator />
</View>
)}
style={styles.webview}
/>
</View>
</SafeAreaView>
);
}
Expand Down Expand Up @@ -248,6 +265,9 @@ function FigmaExplainer() {
}

const styles = StyleSheet.create({
screen: {
flex: 1,
},
safeArea: {
flex: 1,
},
Expand All @@ -272,7 +292,16 @@ const styles = StyleSheet.create({
closeLabel: {
color: '#001A72',
},
loader: { flex: 1, alignItems: 'center', justifyContent: 'center' },
loader: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
},
scrollContent: { paddingBottom: 40 },
titleContainer: {
flexDirection: 'row',
Expand Down
16 changes: 3 additions & 13 deletions PulsarApp/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import QRScanner from '@/components/QRScanner';
import AddConnectionCard from '@/components/home/AddConnectionCard';
import ConnectionList from '@/components/home/ConnectionList';
import HapticsSupportBanner from '@/components/home/HapticsSupportBanner';
import PatternIsPlaying from '@/components/home/PatternIsPlaying';
import NowPlayingToast from '@/components/NowPlayingToast';
import { useConnections } from '@/contexts/ConnectionsContext';

const logo = require('@/assets/images/logo.png');

export default function HomeScreen() {
const { connections, addByCode, remove, reconnect, lastReceived } = useConnections();
const { connections, addByCode, remove, reconnect } = useConnections();
const router = useRouter();

const [connectingCode, setConnectingCode] = useState('');
Expand Down Expand Up @@ -131,11 +131,7 @@ export default function HomeScreen() {

{/* Floating "preset received" banner — pinned above the tab bar so it's
always visible regardless of how many connections fill the list. */}
{lastReceived && (
<View style={styles.toast} pointerEvents="none">
<PatternIsPlaying found={lastReceived.found} name={lastReceived.name} />
</View>
)}
<NowPlayingToast />

<QRScanner visible={scannerOpen} onClose={() => setScannerOpen(false)} onScan={handleScan} />
</SafeAreaView>
Expand All @@ -151,12 +147,6 @@ const styles = StyleSheet.create({
// extra safe-area inset (the SafeAreaView omits the bottom edge above).
paddingBottom: 12,
},
toast: {
position: 'absolute',
left: 15,
right: 15,
bottom: 20,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
Expand Down
27 changes: 27 additions & 0 deletions PulsarApp/components/NowPlayingToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { StyleSheet, View } from 'react-native';

import PatternIsPlaying from '@/components/home/PatternIsPlaying';
import { useConnections } from '@/contexts/ConnectionsContext';

// Floating "preset received" banner - pinned near the bottom so it stays visible
// on any screen while a producer (e.g. the Figma plugin) plays presets on the
// phone. Reads the shared `lastReceived` state, so it works regardless of which
// tab is showing. Non-interactive (pointerEvents="none") so it never eats taps.
export default function NowPlayingToast() {
const { lastReceived } = useConnections();
if (!lastReceived) return null;
return (
<View style={styles.toast} pointerEvents="none">
<PatternIsPlaying found={lastReceived.found} name={lastReceived.name} />
</View>
);
}

const styles = StyleSheet.create({
toast: {
position: 'absolute',
left: 15,
right: 15,
bottom: 20,
},
});
4 changes: 3 additions & 1 deletion figma/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"networkAccess": {
"allowedDomains": [
"https://pulsar-server.swmansion.com",
"wss://pulsar-server.swmansion.com"
"wss://pulsar-server.swmansion.com",
"https://github.com",
"https://github-production-user-asset-6210df.s3.amazonaws.com"
]
}
}
174 changes: 0 additions & 174 deletions figma/preview/dist-embed/index.html

This file was deleted.

10 changes: 8 additions & 2 deletions figma/preview/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import fullscreenIcon from './assets/icon-fullscreen.svg';

const ACTIVE_MS = 900;
const TAP_DEBOUNCE_MS = 250;
// Reads to ride out read-after-write lag when a minimum revision is required.
const REFETCH_ATTEMPTS = 4;

// True when the center point of `inner` lies within `outer`. Used as a fallback
// "which frame does this element belong to?" test for payloads that don't carry
Expand Down Expand Up @@ -66,10 +68,13 @@ export default function App() {
// minimum revision is known, retry briefly to ride out read-after-write lag so
// we never settle on a snapshot older than the change that triggered us.
const refetch = useCallback(async (minRevision?: number) => {
for (let attempt = 0; attempt < 4; attempt++) {
// Up to REFETCH_ATTEMPTS reads; the last one is accepted even if the server
// is still behind minRevision (better a slightly stale snapshot than none).
for (let attempt = 0; attempt < REFETCH_ATTEMPTS; attempt++) {
const res = await readPayload();
if (res.status === 'ok') {
if (minRevision != null && res.revision < minRevision && attempt < 3) {
const isLastAttempt = attempt === REFETCH_ATTEMPTS - 1;
if (minRevision != null && res.revision < minRevision && !isLastAttempt) {
await new Promise((r) => setTimeout(r, 250));
continue;
}
Expand Down Expand Up @@ -446,6 +451,7 @@ export default function App() {
onPlay={playFromList}
onOpenFrame={navigateToFrame}
onShowDetails={setDetailsId}
isMobile={isMobile}
footer={openOnPhoneLink ? <OpenOnPhone deepLink={openOnPhoneLink} /> : undefined}
/>
)}
Expand Down
36 changes: 30 additions & 6 deletions figma/preview/src/components/HapticList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useMemo, type ReactNode } from 'react';
import type { ElementInfo, FrameInfo } from '../types';

// Synthetic bucket id for bindings with no owning frame. Not a real Figma
// node-id, so it can't be opened in the prototype.
const UNASSIGNED = '__unassigned';

export function HapticList({
elements,
frames,
Expand All @@ -12,6 +16,7 @@ export function HapticList({
onPlay,
onOpenFrame,
onShowDetails,
isMobile = false,
footer
}: {
elements: ElementInfo[];
Expand All @@ -22,10 +27,14 @@ export function HapticList({
activeId: string;
onActivate: (id: string) => void;
onPlay: (id: string) => void;
// Open the frame this element lives in (the preview jumps to that screen).
// Optional so the list can render without navigation wired up.
onOpenFrame?: (frameId: string) => void;
// Open a frame in the prototype (the preview jumps to that screen). Passing the
// name lets the host label the screen while it loads. Optional so the list can
// render without navigation wired up.
onOpenFrame?: (frameId: string, frameName?: string) => void;
onShowDetails: (id: string) => void;
// Desktop only surfaces the per-screen "Open screen" button (the side panel
// sits next to the prototype there); on mobile a row tap already opens it.
isMobile?: boolean;
// Optional pinned content below the scrollable list (e.g. the open-on-phone QR).
footer?: ReactNode;
}) {
Expand All @@ -34,7 +43,6 @@ export function HapticList({
// order in the frames Map). Bindings with no frameId fall into a synthetic
// "Unassigned" bucket so they're still reachable.
const groups = useMemo(() => {
const UNASSIGNED = '__unassigned';
const byFrame = new Map<string, ElementInfo[]>();
for (const el of elements) {
const key = el.frameId ?? UNASSIGNED;
Expand Down Expand Up @@ -95,6 +103,22 @@ export function HapticList({
className={`screen-head${group.id === currentFrameId ? ' active' : ''}`}
title={group.name}
>
{/* The screen currently on view gets an "Active screen" label;
every other real screen gets a desktop button to load it into
the prototype iframe (reuses PrototypeView's keep-alive cache). */}
{group.id === currentFrameId ? (
<span className="active-screen-label">Active</span>
) : onOpenFrame && !isMobile && group.id !== UNASSIGNED ? (
<button
type="button"
className="open-screen-btn"
title={`Open ${group.name} in the preview`}
onClick={() => onOpenFrame(group.id, group.name)}
>
<span aria-hidden="true">↗</span>
Open screen
</button>
) : null}
<span className="screen-name">{group.name}</span>
<span className="screen-count">{group.items.length}</span>
</div>
Expand All @@ -111,8 +135,8 @@ export function HapticList({
onPlay(el.id);
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="el-row-inner">
<div className="el-row-main">
<div className="el-haptic">
<span className="el-haptic-name">{el.presetName}</span>
{el.isCustom && <span className="tag tag-custom">Custom</span>}
Expand Down
10 changes: 2 additions & 8 deletions figma/preview/src/components/OpenOnPhone.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useEscapeKey } from '../lib/useEscapeKey';
import QRCode from 'qrcode';
import storeApple from '../assets/store-apple.svg';
import storeGoogle from '../assets/store-google.png';
Expand Down Expand Up @@ -85,14 +86,7 @@ export function OpenOnPhone({ deepLink }: { deepLink: string }) {
}, [collapsed, showDownloadQr, downloadQr]);

// Close the enlarged view on Escape.
useEffect(() => {
if (!expanded) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setExpanded(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [expanded]);
useEscapeKey(() => setExpanded(false), expanded);

// Don't render the section until the QR is ready (avoids an empty box flash).
if (!qr) return null;
Expand Down
Loading