@volter/world-console 3.0.24 → 3.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1712,10 +1712,40 @@ const branchFrameUrl = (name: string, branch: string, f: BoardFrame): string | n
1712
1712
  const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1713
1713
  ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1714
1714
  : all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
1715
- type Screen = { world: string; branch: boolean; frame: BoardFrame; url: string | null; active: boolean; picked: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null; compare: (key: string) => void; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
1715
+ type ViewedPerson = { vendor: string; person: string | null; label: string };
1716
+ type ViewAsMenuData = { world: string; token: string; vendor: string; label?: string; busy: boolean; choose: (person: string | null) => void };
1717
+ type ViewAsNavigation = { frame: string; url: string; pass: string; done: (viewed?: ViewedPerson, error?: string) => void };
1718
+ /** The choice lives in the vendor's cookie; a menu reads people, never password material. */
1719
+ function ViewAsMenu({ data }: { data: ViewAsMenuData }): React.JSX.Element {
1720
+ const [reads, setReads] = useState(0);
1721
+ const people = useDoor<{ viewAs: boolean; people: Array<{ id: string; label: string }> }>(data.token, `/-/${data.world}/twins/${data.vendor}/people`, reads);
1722
+ const offered = useRef(false);
1723
+ if (people.state === 'ok') offered.current = people.data.viewAs;
1724
+ if (!offered.current) return <></>;
1725
+ return <select className="nodrag nopan frame-add" value="" aria-label={`View ${vendorName(data.vendor)} as`} disabled={data.busy}
1726
+ onFocus={() => setReads((n) => n + 1)} onChange={(e) => { if (e.target.value) data.choose(e.target.value === 'signed-out' ? null : e.target.value); }}>
1727
+ <option value="">{data.busy ? 'Signing in…' : data.label ? `View as: ${data.label}` : 'View as…'}</option>
1728
+ <option value="signed-out">Signed out</option>
1729
+ {people.state === 'ok' ? people.data.people.map((p) => <option key={p.id} value={p.id}>{p.label}</option>) : <option disabled>{people.state === 'error' ? people.error : 'Loading people…'}</option>}
1730
+ </select>;
1731
+ }
1732
+ type Screen = { viewAs?: ViewAsMenuData; navigation?: ViewAsNavigation; revision?: number; world: string; branch: boolean; frame: BoardFrame; url: string | null; active: boolean; picked: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null; compare: (key: string) => void; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
1716
1733
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1717
1734
  const { frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
1718
1735
  const key = frameKey(frame);
1736
+ const iframe = useRef<HTMLIFrameElement>(null);
1737
+ const navigation = data.navigation?.frame === key ? data.navigation : undefined;
1738
+ useEffect(() => {
1739
+ if (!navigation) return;
1740
+ const origin = new URL(navigation.url).origin;
1741
+ const on = (event: MessageEvent): void => {
1742
+ if (event.origin !== origin || event.source !== iframe.current?.contentWindow || event.data?.type !== 'volter-view-as' || event.data.pass !== navigation.pass) return;
1743
+ if (event.data.viewed?.vendor !== frame.vendor && !event.data.error) return;
1744
+ navigation.done(event.data.viewed, event.data.error);
1745
+ };
1746
+ window.addEventListener('message', on);
1747
+ return () => window.removeEventListener('message', on);
1748
+ }, [navigation, frame.vendor]);
1719
1749
  const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1720
1750
  // its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
1721
1751
  // there, it is left out, as a canvas app leaves out a frame name that does not fit; selected or in use, it is always shown
@@ -1727,6 +1757,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1727
1757
  <span className="frame-name" onPointerDown={grab}><Mark vendor={frame.vendor} /><span>{frame.title}</span></span>
1728
1758
  {frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} ${branch ? 'on this branch' : 'here, not in a changeset'}: compare`}><Icon name="square-dot" size={14} />{frame.changes}</button>
1729
1759
  : picked ? <button type="button" className="ghost nodrag" onClick={() => compare(key)} title="Compare with a branch of this World" aria-label={`Compare ${frame.title} with a branch`}><Icon name="file-diff" size={14} /></button> : null}
1760
+ {picked && data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
1730
1761
  {picked && others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
1731
1762
  {picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
1732
1763
  </NodeToolbar>
@@ -1738,7 +1769,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1738
1769
  </div>
1739
1770
  <div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
1740
1771
  {url
1741
- ? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1772
+ ? <iframe ref={iframe} key={data.revision ?? 0} title={frame.title} src={navigation?.url ?? url} loading={navigation ? "eager" : "lazy"} style={{ pointerEvents: active ? 'auto' : 'none' }} />
1742
1773
  : <div className="frame-none"><p><strong>{frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : frame.unseen === 'sites-not-routed' ? `This World shows ${frame.title} only at its own local address.` : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet.`}</strong></p><p className="mode">{frame.url}</p></div>}
1743
1774
  </div>
1744
1775
  </div>
@@ -1755,7 +1786,7 @@ function NameField({ name, label, done }: { name: string; label: string; done: (
1755
1786
  }
1756
1787
  /** A section, as a canvas app's: a region of the board with a name, holding whatever frames sit in it. Its name stays the
1757
1788
  * same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
1758
- type SectionData = { key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; renaming: boolean; pick: (key: string) => void; rename: (key: string) => void; renamed: (key: string, next: string | null) => void; remove: (key: string) => void; resized: () => void; /** its name moves it, with what it holds */ grab: (e: React.PointerEvent) => void };
1789
+ type SectionData = { viewAs?: ViewAsMenuData; key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; renaming: boolean; pick: (key: string) => void; rename: (key: string) => void; renamed: (key: string, next: string | null) => void; remove: (key: string) => void; resized: () => void; /** its name moves it, with what it holds */ grab: (e: React.PointerEvent) => void };
1759
1790
  function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
1760
1791
  return (
1761
1792
  <>
@@ -1766,6 +1797,7 @@ function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
1766
1797
  : <button type="button" className={data.picked ? 'nodrag on' : 'nodrag'} onPointerDown={data.grab} onClick={() => data.pick(data.key)} onDoubleClick={() => { if (data.editable) data.rename(data.key); }} title={data.editable ? 'Double-click to rename' : undefined}>
1767
1798
  {data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
1768
1799
  </button>}
1800
+ {data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
1769
1801
  {data.picked && data.editable && !data.renaming ? <button type="button" className="ghost nodrag" onClick={() => data.remove(data.key)} aria-label={`Remove the section ${data.name}`} title="Remove the section (its frames stay)">✕</button> : null}
1770
1802
  </NodeToolbar>
1771
1803
  <div className={data.picked ? 'section-box on' : 'section-box'} />
@@ -1975,6 +2007,13 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
1975
2007
  // the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
1976
2008
  const [opened, setOpened] = useState<Set<string>>(new Set());
1977
2009
  const editable = canWrite && !snapshot && !viewing;
2010
+ const [viewedAs, setViewedAs] = useState<Record<string, { label: string; revision: number }>>({});
2011
+ const [signingAs, setSigningAs] = useState<Record<string, ViewAsNavigation>>({});
2012
+ const busyAs = useRef(new Set<string>());
2013
+ const [askingAs, setAskingAs] = useState<string[]>([]);
2014
+ const viewAsTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
2015
+ const viewAsMounted = useRef(true);
2016
+ useEffect(() => { viewAsMounted.current = true; return () => { viewAsMounted.current = false; for (const timer of viewAsTimers.current) clearTimeout(timer); }; }, []);
1978
2017
  /** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
1979
2018
  const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
1980
2019
  if (!editable) return Promise.resolve();
@@ -2192,10 +2231,45 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2192
2231
  };
2193
2232
  el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
2194
2233
  };
2234
+ const choosePerson = async (vendor: string, person: string | null): Promise<void> => {
2235
+ if (!editable || busyAs.current.has(vendor)) return;
2236
+ const target = nodes.find((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!);
2237
+ if (!target) { toast('Open a page of this vendor on the board first', 'error'); return; }
2238
+ const frame = (target.data as Screen).frame;
2239
+ busyAs.current.add(vendor); setAskingAs([...busyAs.current]);
2240
+ let timer: ReturnType<typeof setTimeout> | undefined;
2241
+ let settled = false;
2242
+ const done = (viewed?: ViewedPerson, error?: string): void => {
2243
+ if (settled || !viewAsMounted.current) return;
2244
+ settled = true;
2245
+ if (timer) { clearTimeout(timer); viewAsTimers.current.delete(timer); }
2246
+ busyAs.current.delete(vendor);
2247
+ setAskingAs([...busyAs.current]);
2248
+ setSigningAs((all) => { const next = { ...all }; delete next[vendor]; return next; });
2249
+ if (error) { toast(error, 'error'); return; }
2250
+ // Reload every frame, including one that had navigated to the sign-in page. No board arrangement is saved.
2251
+ setViewedAs((all) => ({ ...all, [vendor]: { label: viewed?.label ?? all[vendor]?.label ?? '', revision: (all[vendor]?.revision ?? 0) + 1 } }));
2252
+ };
2253
+ try {
2254
+ const land = `/${name}/${vendor}${(frame.at as { screen: string }).screen}`;
2255
+ const answer = await door(SESSION, `/-/${name}/twins/${vendor}/session`, { method: 'POST', body: JSON.stringify({ person, land }) });
2256
+ if (!answer.ok) { done(undefined, await failure(answer)); return; }
2257
+ const opened = await answer.json() as { url: string };
2258
+ if (!viewAsMounted.current) return;
2259
+ const pass = new URLSearchParams(new URL(opened.url).hash.slice(1)).get('pass');
2260
+ if (!pass) throw new Error('The World did not return a sign-in pass.');
2261
+ setSigningAs((all) => ({ ...all, [vendor]: { frame: target.id, url: opened.url, pass, done } }));
2262
+ // The pass lasts a minute; also recover if its frame was removed before it answered.
2263
+ timer = setTimeout(() => done(undefined, 'The sign-in did not finish. Choose View as again.'), 60_000);
2264
+ viewAsTimers.current.add(timer);
2265
+ } catch (error) { done(undefined, error instanceof Error ? error.message : 'The World did not answer.'); }
2266
+ };
2267
+ const viewAsMenu = (vendor: string | null): ViewAsMenuData | undefined => editable && token === SESSION && vendor && nodes.some((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!)
2268
+ ? { world: name, token, vendor, label: viewedAs[vendor]?.label, busy: askingAs.includes(vendor), choose: (person) => { void choosePerson(vendor, person); } } : undefined;
2195
2269
  // what each node shows: in use, selected, and what can be done to it
2196
2270
  const shownNodes = nodes.map((n) => (n.type === 'section'
2197
- ? { ...n, data: { ...n.data, grab: grab(n.id), picked: picked === n.id, editable, renaming: editable && renaming === (n.data as { key: string }).key, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, renamed: renamedSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
2198
- : { ...n, data: { ...n.data, grab: grab(n.id), active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(n.id)) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
2271
+ ? { ...n, data: { ...n.data, viewAs: viewAsMenu((n.data as SectionData).vendor), grab: grab(n.id), picked: picked === n.id, editable, renaming: editable && renaming === (n.data as { key: string }).key, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, renamed: renamedSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
2272
+ : { ...n, data: { ...n.data, viewAs: (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at! ? viewAsMenu((n.data as Screen).frame.vendor) : undefined, navigation: editable ? signingAs[(n.data as Screen).frame.vendor] : undefined, revision: viewedAs[(n.data as Screen).frame.vendor]?.revision, grab: grab(n.id), active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(n.id)) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
2199
2273
  // the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
2200
2274
  useEffect(() => {
2201
2275
  const on = (e: KeyboardEvent): void => {