@nualang/nualang-ui-components 0.1.1421 → 0.1.1423

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.
@@ -101,7 +101,8 @@ function Bot({
101
101
  aiChatbotConversationHistory,
102
102
  isUserInternal,
103
103
  handleStudentMisuse,
104
- username
104
+ username,
105
+ trackRecommendedEvent
105
106
  }) {
106
107
  const theme = useTheme();
107
108
  const {
@@ -121,6 +122,9 @@ function Bot({
121
122
  browserSupportsSpeechRecognition,
122
123
  clearAttemptAudio
123
124
  } = useRecognition({
125
+ trackRecommendedEvent,
126
+ exerciseName,
127
+ learnLang,
124
128
  openSnackbar
125
129
  });
126
130
  const {
@@ -119,10 +119,11 @@ function RoleplayGame({
119
119
  classes
120
120
  } = useStyles();
121
121
  const theme = useTheme();
122
+ const isDisabled = isGameHidden || isRoleplayEmpty;
122
123
  return /*#__PURE__*/_jsxs(ListItem, {
123
124
  alignItems: "flex-start",
124
- disabled: isGameHidden || isRoleplayEmpty,
125
- onClick: authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
125
+ disabled: isDisabled,
126
+ onClick: isDisabled ? undefined : authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
126
127
  button: true,
127
128
  divider: true,
128
129
  classes: {
@@ -143,7 +144,11 @@ function RoleplayGame({
143
144
  },
144
145
  "&:focus": {
145
146
  backgroundColor: "transparent"
146
- }
147
+ },
148
+ ...(isDisabled && {
149
+ opacity: 0.5,
150
+ pointerEvents: "none"
151
+ })
147
152
  },
148
153
  children: [/*#__PURE__*/_jsx(ListItemAvatar, {
149
154
  children: gameIcon ? /*#__PURE__*/_jsx(Avatar, {
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, useCallback, useRef } from "react";
2
2
  import { Link as RouterLink, useNavigate, useParams } from "react-router";
3
- import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar } from "@mui/material";
3
+ import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar, Chip } from "@mui/material";
4
4
  import { useTheme, alpha } from "@mui/material/styles";
5
5
  import { makeStyles } from "tss-react/mui";
6
6
  import CheckBox from "@mui/icons-material/CheckBox";
@@ -26,6 +26,7 @@ import CopyIcon from "@mui/icons-material/FileCopy";
26
26
  import ImageSearchIcon from "@mui/icons-material/ImageSearch";
27
27
  import Skeleton from "@mui/material/Skeleton";
28
28
  import OndemandVideoIcon from "@mui/icons-material/OndemandVideo";
29
+ import DifficultyIcon from "@mui/icons-material/School";
29
30
  import ColorLinearProgress from "../../Misc/ColorLinearProgress/ColorLinearProgress";
30
31
  import { calcCompletions, calcPercentageCompletion } from "../../utils/index";
31
32
  import SelectClassroom from "../../Dialogs/SelectClassroom/SelectClassroom";
@@ -336,7 +337,10 @@ function Topic({
336
337
  sectionIndex,
337
338
  index,
338
339
  moveTopic,
340
+ moveTopicAcrossSections,
339
341
  onDropSection,
342
+ onMoveTopicToSection,
343
+ onCancelMove,
340
344
  disableDrag,
341
345
  classroomId,
342
346
  courseSettings,
@@ -376,14 +380,34 @@ function Topic({
376
380
  const [{
377
381
  handlerId
378
382
  }, drop] = useDrop({
379
- type: `${sectionId}-topic`,
380
- accept: `${sectionId}-topic`,
383
+ // A single shared type lets a topic be dropped into any section, not just
384
+ // its own. Cross-section vs within-section is disambiguated in `hover`.
385
+ accept: "topic",
386
+ collect: monitor => ({
387
+ handlerId: monitor.getHandlerId()
388
+ }),
381
389
  hover(item, monitor) {
382
390
  if (!ref.current) {
383
391
  return;
384
392
  }
385
- const dragIndex = item.index;
386
393
  const hoverIndex = index;
394
+
395
+ // Cross-section move: the dragged topic originates in another section.
396
+ if (item.sectionIndex !== sectionIndex && moveTopicAcrossSections) {
397
+ // Insert before or after this topic depending on cursor position.
398
+ const hoverBoundingRect = ref.current.getBoundingClientRect();
399
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
400
+ const clientOffset = monitor.getClientOffset();
401
+ if (!clientOffset) return;
402
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
403
+ const targetIndex = hoverClientY < hoverMiddleY ? hoverIndex : hoverIndex + 1;
404
+ moveTopicAcrossSections(item.sectionIndex, item.topicId, sectionIndex, targetIndex);
405
+ item.sectionId = sectionId;
406
+ item.sectionIndex = sectionIndex;
407
+ item.index = targetIndex;
408
+ return;
409
+ }
410
+ const dragIndex = item.index;
387
411
  // Don't replace items with themselves
388
412
  if (dragIndex === hoverIndex) {
389
413
  return;
@@ -419,11 +443,18 @@ function Topic({
419
443
  const [{
420
444
  isDragging
421
445
  }, drag] = useDrag({
422
- type: `${sectionId}-topic`,
423
- canDrag: isCreator && isLargeScreen && !disableDrag ? true : false,
446
+ type: "topic",
447
+ // Touch is supported via react-dnd-multi-backend, so drag is not gated on
448
+ // screen size — only on being the course creator.
449
+ canDrag: isCreator && !disableDrag ? true : false,
424
450
  item: {
425
451
  topicId,
426
452
  index,
453
+ sectionId,
454
+ sectionIndex,
455
+ // Immutable origin, used on drop to detect a re-parent and to persist it.
456
+ originSectionId: sectionId,
457
+ originSectionIndex: sectionIndex,
427
458
  primary: topicName,
428
459
  secondary: description,
429
460
  image: picture
@@ -431,8 +462,23 @@ function Topic({
431
462
  collect: monitor => ({
432
463
  isDragging: monitor.isDragging()
433
464
  }),
434
- end: (dropResult, monitor) => {
435
- onDropSection();
465
+ end: (item, monitor) => {
466
+ // Dropped outside any section (or cancelled with Esc) — revert.
467
+ if (!monitor.didDrop()) {
468
+ if (onCancelMove) onCancelMove();
469
+ return;
470
+ }
471
+ if (item && item.originSectionId && item.originSectionId !== item.sectionId && onMoveTopicToSection) {
472
+ // The topic changed section during the drag — persist the re-parent.
473
+ onMoveTopicToSection({
474
+ sourceSectionId: item.originSectionId,
475
+ targetSectionId: item.sectionId,
476
+ topicId: item.topicId
477
+ });
478
+ } else {
479
+ // Same-section reorder — persist the new order.
480
+ onDropSection();
481
+ }
436
482
  }
437
483
  });
438
484
  const opacity = isDragging ? 0 : 1;
@@ -505,7 +551,35 @@ function Topic({
505
551
  alignItems: "center",
506
552
  mt: 1
507
553
  },
508
- children: [topic?.pdf && /*#__PURE__*/_jsx(Box, {
554
+ children: [topic?.difficulty && /*#__PURE__*/_jsxs(Box, {
555
+ sx: {
556
+ display: "inline-flex",
557
+ alignItems: "center",
558
+ gap: 0.5,
559
+ mr: 1
560
+ },
561
+ role: "group",
562
+ "aria-label": `${t("difficulty")}: ${t(`card_${topic.difficulty}`)}`,
563
+ children: [/*#__PURE__*/_jsx(DifficultyIcon, {
564
+ fontSize: "small",
565
+ sx: {
566
+ marginRight: 1,
567
+ color: theme => theme.palette.text.secondary
568
+ }
569
+ }), /*#__PURE__*/_jsx(Chip, {
570
+ label: t(`card_${topic.difficulty}`),
571
+ size: "small",
572
+ sx: theme => ({
573
+ height: 24,
574
+ backgroundColor: theme.palette.action.selected,
575
+ color: theme.palette.text.secondary,
576
+ "& .MuiChip-label": {
577
+ fontSize: "0.75rem",
578
+ px: 0.75
579
+ }
580
+ })
581
+ })]
582
+ }), topic?.pdf && /*#__PURE__*/_jsx(Box, {
509
583
  sx: {
510
584
  display: "flex",
511
585
  alignItems: "center"
@@ -789,6 +863,7 @@ function Section({
789
863
  isCommunityAccessRestricted = false,
790
864
  moveSection,
791
865
  moveTopic,
866
+ moveTopicAcrossSections,
792
867
  index,
793
868
  onDropSection,
794
869
  teachClassrooms,
@@ -922,8 +997,37 @@ function Section({
922
997
  onDropSection();
923
998
  }
924
999
  });
1000
+
1001
+ // Section-level drop target for topics. Positioning within a non-empty
1002
+ // section is handled by the per-topic hover; this target handles dropping a
1003
+ // topic onto an EMPTY section and drives the "valid drop target" highlight.
1004
+ const [{
1005
+ isTopicOver,
1006
+ canDropTopic,
1007
+ draggedTopicItem
1008
+ }, dropTopicIntoSection] = useDrop({
1009
+ accept: "topic",
1010
+ hover(item, monitor) {
1011
+ if (!monitor.isOver({
1012
+ shallow: true
1013
+ })) return;
1014
+ if (item.sectionIndex === index) return;
1015
+ if ((topics || []).length > 0) return; // non-empty: topic hover handles it
1016
+ if (!moveTopicAcrossSections) return;
1017
+ moveTopicAcrossSections(item.sectionIndex, item.topicId, index, 0);
1018
+ item.sectionId = sectionId;
1019
+ item.sectionIndex = index;
1020
+ item.index = 0;
1021
+ },
1022
+ collect: monitor => ({
1023
+ isTopicOver: monitor.isOver(),
1024
+ canDropTopic: monitor.canDrop(),
1025
+ draggedTopicItem: monitor.getItem()
1026
+ })
1027
+ });
1028
+ const isTopicDropHighlight = isTopicOver && canDropTopic && draggedTopicItem && draggedTopicItem.originSectionId !== sectionId;
925
1029
  const opacity = isDragging ? 0 : 1;
926
- drag(drop(ref));
1030
+ drag(dropTopicIntoSection(drop(ref)));
927
1031
  const [anchorEl, setAnchorEl] = useState(null);
928
1032
  const handleClickMenu = event => {
929
1033
  setAnchorEl(event.currentTarget);
@@ -947,6 +1051,14 @@ function Section({
947
1051
  style: {
948
1052
  opacity
949
1053
  },
1054
+ "data-cy": "section-drop-zone",
1055
+ sx: isTopicDropHighlight ? {
1056
+ outline: theme => `2px dashed ${theme.palette.primary.main}`,
1057
+ outlineOffset: "-4px",
1058
+ borderRadius: 1,
1059
+ backgroundColor: theme => alpha(theme.palette.primary.main, 0.06),
1060
+ transition: "background-color 120ms ease"
1061
+ } : undefined,
950
1062
  component: "nav",
951
1063
  "aria-labelledby": "section-list-subheader",
952
1064
  subheader: /*#__PURE__*/_jsx(ListSubheader, {
@@ -1139,6 +1251,7 @@ function Section({
1139
1251
  handleUpdateSectionTopic: handleUpdateSectionTopic,
1140
1252
  handleDuplicateTopic: handleDuplicateTopic,
1141
1253
  moveTopic: moveTopic,
1254
+ moveTopicAcrossSections: moveTopicAcrossSections,
1142
1255
  isMember: isMember,
1143
1256
  disableDrag: disableDrag,
1144
1257
  courseSettings: courseSettings,
@@ -1341,6 +1454,7 @@ function CourseOutline({
1341
1454
  handleUpdateSections,
1342
1455
  handleDeleteSectionVideo,
1343
1456
  handleUpdateTopics,
1457
+ handleMoveTopic,
1344
1458
  isPublic,
1345
1459
  handleSwitchChange,
1346
1460
  isLoading,
@@ -1360,6 +1474,13 @@ function CourseOutline({
1360
1474
  percentage: 0
1361
1475
  });
1362
1476
  const [sects, setSects] = useState([]);
1477
+
1478
+ // Mirror `sects` into a ref so drop handlers can read the latest optimistic
1479
+ // order without being re-created on every change.
1480
+ const sectsRef = useRef(sects);
1481
+ useEffect(() => {
1482
+ sectsRef.current = sects;
1483
+ }, [sects]);
1363
1484
  useEffect(() => {
1364
1485
  if (isMember) {
1365
1486
  const sectionsWithProgress = calcCompletions(sections, completions, null, isChallengeModeStudent, isReadWriteModeStudent);
@@ -1386,21 +1507,106 @@ function CourseOutline({
1386
1507
  setSects(newSections);
1387
1508
  }, [sects]);
1388
1509
  const moveTopic = useCallback((sectionIndex, dragIndex, hoverIndex) => {
1389
- const dragTopic = sects[sectionIndex].topics[dragIndex];
1390
- const newTopics = update(sects[sectionIndex].topics, {
1391
- $splice: [[dragIndex, 1], [hoverIndex, 0, {
1392
- ...dragTopic,
1393
- idx: hoverIndex
1394
- }]]
1510
+ // Functional update so rapid successive hover events always build on the
1511
+ // latest state and cannot clobber one another (which would desync the
1512
+ // drag item's index from the actual source order).
1513
+ setSects(current => {
1514
+ const section = current[sectionIndex];
1515
+ if (!section) return current;
1516
+ const dragTopic = (section.topics || [])[dragIndex];
1517
+ if (!dragTopic) return current;
1518
+ const newTopics = update(section.topics, {
1519
+ $splice: [[dragIndex, 1], [hoverIndex, 0, {
1520
+ ...dragTopic,
1521
+ idx: hoverIndex
1522
+ }]]
1523
+ });
1524
+ return update(current, {
1525
+ $splice: [[sectionIndex, 1], [sectionIndex, 0, {
1526
+ ...section,
1527
+ topics: newTopics
1528
+ }]]
1529
+ });
1395
1530
  });
1396
- const newSections = update(sects, {
1397
- $splice: [[sectionIndex, 1], [sectionIndex, 0, {
1398
- ...sects[sectionIndex],
1399
- topics: newTopics
1400
- }]]
1531
+ }, []);
1532
+
1533
+ // Optimistically move a topic from one section to another at a given hover
1534
+ // position, restamping its section reference and idx. Uses a functional
1535
+ // update so repeated drag-hover events always build on the latest state.
1536
+ const moveTopicAcrossSections = useCallback((fromSectionIndex, topicId, toSectionIndex, hoverIndex) => {
1537
+ setSects(current => {
1538
+ if (fromSectionIndex === toSectionIndex) return current;
1539
+ const fromSection = current[fromSectionIndex];
1540
+ const toSection = current[toSectionIndex];
1541
+ if (!fromSection || !toSection) return current;
1542
+ const fromTopics = fromSection.topics || [];
1543
+ const toTopics = toSection.topics || [];
1544
+ // Identify the dragged topic by its stable id, not by a positional
1545
+ // index: the drag item's index can drift during a fast drag, which
1546
+ // would otherwise re-parent the wrong (e.g. last) topic. Also makes
1547
+ // repeated hover fires idempotent once the topic has already moved.
1548
+ const movedTopic = fromTopics.find(tpc => tpc.topicId === topicId);
1549
+ if (!movedTopic) return current;
1550
+ const targetSectionId = toSection.sectionId;
1551
+ const newFromTopics = fromTopics.filter(tpc => tpc.topicId !== topicId);
1552
+ const clampedIndex = Math.max(0, Math.min(hoverIndex, toTopics.length));
1553
+ const repositionedTopic = {
1554
+ ...movedTopic,
1555
+ sectionId: targetSectionId,
1556
+ courseSectionId: `${courseId}|${targetSectionId}`,
1557
+ idx: clampedIndex
1558
+ };
1559
+ const newToTopics = [...toTopics.slice(0, clampedIndex), repositionedTopic, ...toTopics.slice(clampedIndex)];
1560
+ return current.map((s, i) => {
1561
+ if (i === fromSectionIndex) return {
1562
+ ...s,
1563
+ topics: newFromTopics
1564
+ };
1565
+ if (i === toSectionIndex) return {
1566
+ ...s,
1567
+ topics: newToTopics
1568
+ };
1569
+ return s;
1570
+ });
1401
1571
  });
1402
- setSects(newSections);
1403
- }, [sects]);
1572
+ }, [courseId]);
1573
+
1574
+ // Revert the optimistic order back to the authoritative `sections` prop —
1575
+ // used when a drag is cancelled / dropped outside any section.
1576
+ const resetSects = useCallback(() => {
1577
+ if (isMember) {
1578
+ setSects(calcCompletions(sections, completions, null, isChallengeModeStudent, isReadWriteModeStudent));
1579
+ } else {
1580
+ setSects(sections);
1581
+ }
1582
+ }, [isMember, sections, completions, isChallengeModeStudent, isReadWriteModeStudent]);
1583
+
1584
+ // On drop of a re-parented topic, hand the source/target ordering to the
1585
+ // page-level handler so it can persist the move + reindex both sections.
1586
+ const dispatchMoveTopic = useCallback(({
1587
+ sourceSectionId,
1588
+ targetSectionId,
1589
+ topicId
1590
+ }) => {
1591
+ if (typeof handleMoveTopic !== "function") {
1592
+ resetSects();
1593
+ return;
1594
+ }
1595
+ const current = sectsRef.current || [];
1596
+ const targetSection = current.find(s => s.sectionId === targetSectionId);
1597
+ const sourceSection = current.find(s => s.sectionId === sourceSectionId);
1598
+ if (!targetSection) return;
1599
+ const movedTopic = (targetSection.topics || []).find(topicItem => topicItem.topicId === topicId);
1600
+ if (!movedTopic) return;
1601
+ handleMoveTopic({
1602
+ courseId,
1603
+ sourceSectionId,
1604
+ targetSectionId,
1605
+ topic: movedTopic,
1606
+ targetTopics: targetSection.topics || [],
1607
+ sourceTopics: sourceSection ? sourceSection.topics || [] : []
1608
+ });
1609
+ }, [courseId, handleMoveTopic, resetSects]);
1404
1610
  const {
1405
1611
  percentage,
1406
1612
  completed,
@@ -1534,6 +1740,9 @@ function CourseOutline({
1534
1740
  isCreator: isCreator,
1535
1741
  moveSection: moveSection,
1536
1742
  moveTopic: moveTopic,
1743
+ moveTopicAcrossSections: moveTopicAcrossSections,
1744
+ onMoveTopicToSection: dispatchMoveTopic,
1745
+ onCancelMove: resetSects,
1537
1746
  handleUpdateSections: handleUpdateSections,
1538
1747
  handleUpdateTopics: handleUpdateTopics,
1539
1748
  isPublic: isPublic,
@@ -1,22 +1,88 @@
1
1
  import { render } from "@testing-library/react";
2
- import { describe, it } from "vitest";
2
+ import { describe, it, expect, vi } from "vitest";
3
3
  import CourseOutline from "./CourseOutline";
4
4
  import { props } from "./CourseOutline.stories";
5
5
  import { DndProvider } from "react-dnd";
6
6
  import { TestBackend } from "react-dnd-test-backend";
7
7
  import { MemoryRouter } from "react-router";
8
+
9
+ // Two sections — one populated, one empty — to exercise cross-section drag
10
+ // wiring (drop zones, draggable topics, re-parent handler threading). Full
11
+ // pointer-driven drag interaction is covered by the app-level Playwright spec,
12
+ // since react-dnd hover geometry relies on getBoundingClientRect, which returns
13
+ // zeros under jsdom.
8
14
  import { jsx as _jsx } from "react/jsx-runtime";
15
+ const topic = (id, name, idx) => ({
16
+ topicId: id,
17
+ topicName: name,
18
+ idx,
19
+ sectionId: "section-a",
20
+ roleplays: [],
21
+ bots: [],
22
+ completions: []
23
+ });
24
+ const crossSectionProps = (overrides = {}) => ({
25
+ ...props,
26
+ isCreator: true,
27
+ isMember: false,
28
+ courseId: "course-1",
29
+ sections: [{
30
+ sectionId: "section-a",
31
+ sectionName: "Section A",
32
+ idx: 0,
33
+ topics: [topic("topic-1", "Topic 1", 0), topic("topic-2", "Topic 2", 1)]
34
+ }, {
35
+ sectionId: "section-b",
36
+ sectionName: "Section B",
37
+ idx: 1,
38
+ topics: []
39
+ }],
40
+ handleMoveTopic: vi.fn(),
41
+ ...overrides
42
+ });
43
+ const renderOutline = p => render(/*#__PURE__*/_jsx(MemoryRouter, {
44
+ children: /*#__PURE__*/_jsx(DndProvider, {
45
+ backend: TestBackend,
46
+ children: /*#__PURE__*/_jsx(CourseOutline, {
47
+ ...p
48
+ })
49
+ })
50
+ }));
9
51
  describe("CourseOutline", () => {
10
52
  it("renders without crashing", () => {
11
53
  const {
12
54
  unmount
13
- } = render(/*#__PURE__*/_jsx(MemoryRouter, {
14
- children: /*#__PURE__*/_jsx(DndProvider, {
15
- backend: TestBackend,
16
- children: /*#__PURE__*/_jsx(CourseOutline, {
17
- ...props
18
- })
19
- })
55
+ } = renderOutline(props);
56
+ unmount();
57
+ });
58
+ it("renders without crashing when cross-section drag handlers are provided", () => {
59
+ const {
60
+ unmount
61
+ } = renderOutline(crossSectionProps());
62
+ unmount();
63
+ });
64
+ it("renders a drop zone for every section so topics can be dropped between them", () => {
65
+ const {
66
+ container,
67
+ unmount
68
+ } = renderOutline(crossSectionProps());
69
+ // One drop zone per rendered section (source + target).
70
+ expect(container.querySelectorAll('[data-cy="section-drop-zone"]').length).toBe(2);
71
+ unmount();
72
+ });
73
+ it("renders each topic as a draggable list item for the course creator", () => {
74
+ const {
75
+ container,
76
+ unmount
77
+ } = renderOutline(crossSectionProps());
78
+ expect(container.querySelectorAll('[data-cy="topic-list-item"]').length).toBe(2);
79
+ unmount();
80
+ });
81
+ it("does not throw when handleMoveTopic is omitted (drag stays inert)", () => {
82
+ const {
83
+ unmount
84
+ } = renderOutline(crossSectionProps({
85
+ handleMoveTopic: undefined
20
86
  }));
21
87
  unmount();
22
88
  });
@@ -9,14 +9,46 @@ const ENDPOINT = import.meta.env.REACT_APP_GCLOUD_SPEECH || "wss://speech-rec-se
9
9
  const socket = io(ENDPOINT, {
10
10
  withCredentials: true,
11
11
  autoConnect: false,
12
- transports: ["websocket", "polling"] // use WebSocket, fallback to polling if necessary
13
- });
14
- socket.on("connect_error", error => {
15
- // revert to classic upgrade
16
- // socket.io.opts.transports = ["polling", "websocket"];
17
- console.error(error);
12
+ transports: ["websocket", "polling"],
13
+ // use WebSocket, fallback to polling if necessary
14
+ // Without this, when websocket is listed first and fails outright (e.g. a
15
+ // school firewall blocking the WS upgrade), engine.io does NOT fall back to
16
+ // polling for that attempt - it just fails the whole connect_error and retries
17
+ // websocket again on the next reconnection attempt, looping forever on
18
+ // networks that block websocket entirely.
19
+ tryAllTransports: true
18
20
  });
19
21
 
22
+ /**
23
+ * Builds a single diagnostic message for a connect_error analytics event.
24
+ * `error.message` for a WebSocket transport failure is (by design of the
25
+ * browser WebSocket API) always the generic string "websocket error" - the
26
+ * spec deliberately withholds the underlying reason (DNS/TLS/refused/timed
27
+ * out) from JS to avoid leaking network topology info to a page. `error.type`
28
+ * and `error.description` are the only other fields engine.io-client
29
+ * attaches, and for polling transport failures `description` is often the
30
+ * XHR object (so `description.status` gives the HTTP status code) - folded
31
+ * into one field here rather than several, since they're only ever useful
32
+ * together and only for this one event.
33
+ */
34
+ function describeConnectError(error) {
35
+ const description = error?.description;
36
+ let descriptionDetail = "none";
37
+ if (description && typeof description === "object") {
38
+ if (typeof description.status === "number") {
39
+ descriptionDetail = `http_status_${description.status}`;
40
+ } else if (description.message) {
41
+ descriptionDetail = `${description.message}`;
42
+ }
43
+ } else if (description) {
44
+ descriptionDetail = `${description}`;
45
+ }
46
+ const transport = socket.io?.engine?.transport?.name;
47
+ return {
48
+ A4_error_message: `${error?.message} (type=${error?.type}, description=${descriptionDetail}, transport=${transport}, online=${navigator.onLine})`
49
+ };
50
+ }
51
+
20
52
  // audioStream constraints
21
53
  const constraints = {
22
54
  audio: true,
@@ -24,6 +56,21 @@ const constraints = {
24
56
  };
25
57
  const version = ["v1", "v2"].includes(import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION) ? import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION : "v1";
26
58
  const audioRecordingLimit = 60000;
59
+
60
+ // Safari (16.4+) exposes a page-global AudioSession whose category WebKit
61
+ // otherwise flips to playback-only when TTS/<audio> plays, after which
62
+ // getUserMedia throws "AudioSession category is not compatible with audio
63
+ // capture". Pinning it to "play-and-record" keeps mic capture available even
64
+ // after audio playback. No-op where the API is unsupported (Chrome/Firefox).
65
+ function ensurePlayAndRecordAudioSession() {
66
+ try {
67
+ if (typeof navigator !== "undefined" && navigator.audioSession) {
68
+ navigator.audioSession.type = "play-and-record";
69
+ }
70
+ } catch (error) {
71
+ logger.warn(`Failed to set audioSession type: ${error}`);
72
+ }
73
+ }
27
74
  export default function useRecognition(props = {}) {
28
75
  const {
29
76
  currentPhraseText,
@@ -98,6 +145,12 @@ export default function useRecognition(props = {}) {
98
145
  throw e;
99
146
  }
100
147
  }
148
+
149
+ // Set as early as the exercise mounts, before any TTS playback can claim a
150
+ // playback-only session on Safari.
151
+ useEffect(() => {
152
+ ensurePlayAndRecordAudioSession();
153
+ }, []);
101
154
  useEffect(() => {
102
155
  function onConnect() {
103
156
  hasAlertedConnectionErrorRef.current = false;
@@ -105,9 +158,13 @@ export default function useRecognition(props = {}) {
105
158
  // socket.io.engine.transport.name is "polling" here only when the
106
159
  // websocket attempt failed and it fell back - the clearest signal we
107
160
  // have that a student couldn't connect via websocket.
161
+ const transport = socket.io.engine.transport.name;
108
162
  trackConnectionDiagnostics("speech_recognition_socket_connected", {
109
- A4_transport: socket.io.engine.transport.name
163
+ A4_transport: transport
110
164
  });
165
+ if (transport === "polling") {
166
+ recognitionAlert("Using a slower backup connection for speech - ask your school's IT team to allow WebSocket connections.", "warning");
167
+ }
111
168
  }
112
169
  function onDisconnect() {
113
170
  setIsSocketConnected(false);
@@ -151,9 +208,7 @@ export default function useRecognition(props = {}) {
151
208
  }
152
209
  function onConnectError(error) {
153
210
  console.error(error);
154
- trackConnectionDiagnostics("speech_recognition_connect_error", {
155
- A4_error_message: `${error?.message}`
156
- });
211
+ trackConnectionDiagnostics("speech_recognition_connect_error", describeConnectError(error));
157
212
  // Without this, a blocked/failed connection (e.g. a school network
158
213
  // firewall blocking the websocket) leaves the mic button silently
159
214
  // disabled with no feedback to the student.
@@ -342,6 +397,8 @@ export default function useRecognition(props = {}) {
342
397
  }
343
398
  };
344
399
  if (navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
400
+ // Re-assert in case TTS playback downgraded the session since mount.
401
+ ensurePlayAndRecordAudioSession();
345
402
  navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).then(() => {
346
403
  isStartingRef.current = false;
347
404
  // socket.connected is checked directly (not the isSocketConnected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1421",
3
+ "version": "0.1.1423",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "react-swipeable-views-utils": "^0.14.0",
84
84
  "regenerator-runtime": "^0.14.1",
85
85
  "rehype-raw": "^7.0.0",
86
- "socket.io-client": "^4.7.1",
86
+ "socket.io-client": "^4.8.3",
87
87
  "styled-components": "^6.3.8",
88
88
  "tss-react": "^4.9.16",
89
89
  "uifx": "^2.0",