@nualang/nualang-ui-components 0.1.1436 → 0.1.1437

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.
@@ -83,7 +83,9 @@ export default function CreateAssignmentDialog({
83
83
  return response;
84
84
  }, {
85
85
  filters: {
86
- courseIds: selectedClassroomCourses?.toString()
86
+ courseIds: selectedClassroomCourses?.toString(),
87
+ // Sections/topics are only returned when verbose; needed for the title autofill
88
+ verbose: true
87
89
  }
88
90
  }, {
89
91
  enabled: !!assignment.classroomId
@@ -862,7 +862,7 @@ function CourseCard({
862
862
  handleViewCourse(parentId);
863
863
  }
864
864
  };
865
- const courseSections = sectionsQuery.isSuccess ? sectionsQuery.data.Items : [];
865
+ const courseSections = sectionsQuery.isSuccess && sectionsQuery.data?.Items || [];
866
866
  const topicDifficulties = sectionsQuery.isSuccess ? [...new Set(courseSections.flatMap(section => section.topics || []).map(topic => topic.difficulty).filter(Boolean))] : [];
867
867
  const deleteCourse = async () => {
868
868
  const confirmed = await confirm(t("delete_course"), t("delete_course_confirmation"), "", true, t("course_deletion_note", {
@@ -1263,7 +1263,7 @@ function CourseCard({
1263
1263
  dialogTitle: courseName,
1264
1264
  open: isOutlineOpen,
1265
1265
  handleClose: handleCloseOutline,
1266
- sections: sectionsQuery.isSuccess ? sectionsQuery.data.Items : [],
1266
+ sections: courseSections,
1267
1267
  isLoading: sectionsQuery.isLoading,
1268
1268
  isMember: isMember,
1269
1269
  completions: completions,
@@ -14,7 +14,7 @@ const useStyles = makeStyles()(theme => ({
14
14
  overflow: "auto",
15
15
  padding: theme.spacing(2),
16
16
  "@media print": {
17
- position: "right"
17
+ overflow: "visible"
18
18
  }
19
19
  }
20
20
  }));
@@ -100,11 +100,11 @@ function Answers({
100
100
  if (!isLiveGame) {
101
101
  if (value.correct) {
102
102
  setAnsweredCorrectly(true);
103
- handleCorrectQuestionAnswer(question, value);
103
+ handleCorrectQuestionAnswer(question, value, answers);
104
104
  }
105
105
  if (!value.correct) {
106
106
  setAnsweredCorrectly(false);
107
- handleIncorrectQuestionAnswer(question, value);
107
+ handleIncorrectQuestionAnswer(question, value, answers);
108
108
  }
109
109
  }
110
110
  };
@@ -50,6 +50,24 @@ function ClassroomSettings({
50
50
  }
51
51
  }, [scrollToDiscuss]);
52
52
  const isInviteFeatureDisabled = classroomId && classroomId.toLowerCase().includes("wayside");
53
+
54
+ // Returns the translation key explaining why "public" is disabled, or null if allowed
55
+ const getPublicVisibilityDisabledReason = () => {
56
+ if (isUpdateClassroom && isInviteFeatureDisabled) {
57
+ return "public_visibility_disabled_wayside";
58
+ }
59
+ if (subscription.isSubscriptionLoaded === false) {
60
+ return "public_visibility_disabled_loading";
61
+ }
62
+ if (subscription.subscriptionStatus === "trialing") {
63
+ return "public_visibility_disabled_trial";
64
+ }
65
+ if (!subscription.isPaidUser) {
66
+ return subscription.isUpgradePossible ? "upgrade_plan_explanation" : "upgrade_subscription_student";
67
+ }
68
+ return null;
69
+ };
70
+ const publicVisibilityDisabledReason = getPublicVisibilityDisabledReason();
53
71
  return /*#__PURE__*/_jsxs(_Fragment, {
54
72
  children: [/*#__PURE__*/_jsx(Typography, {
55
73
  children: t("limit_access_classroom_visibility_private_including_enrolment_key")
@@ -85,21 +103,8 @@ function ClassroomSettings({
85
103
  onBlur: handleBlur,
86
104
  helperText: touched.visibility ? errors.visibility : "",
87
105
  error: touched.visibility && Boolean(errors.visibility),
88
- children: [isUpdateClassroom ? !subscription.isPaidUser || subscription.subscriptionStatus === "trialing" || isInviteFeatureDisabled ? /*#__PURE__*/_jsx(Tooltip, {
89
- title: subscription.isUpgradePossible ? t("upgrade_plan_explanation") : t("upgrade_subscription_student"),
90
- placement: "top",
91
- children: /*#__PURE__*/_jsx(FormControlLabel, {
92
- value: "public",
93
- control: /*#__PURE__*/_jsx(Radio, {}),
94
- label: t("public"),
95
- disabled: true
96
- })
97
- }) : /*#__PURE__*/_jsx(FormControlLabel, {
98
- value: "public",
99
- control: /*#__PURE__*/_jsx(Radio, {}),
100
- label: t("public")
101
- }) : !subscription.isPaidUser || subscription.subscriptionStatus === "trialing" ? /*#__PURE__*/_jsx(Tooltip, {
102
- title: subscription.isUpgradePossible ? t("upgrade_plan_explanation") : t("upgrade_subscription_student"),
106
+ children: [publicVisibilityDisabledReason ? /*#__PURE__*/_jsx(Tooltip, {
107
+ title: t(publicVisibilityDisabledReason),
103
108
  placement: "top",
104
109
  children: /*#__PURE__*/_jsx(FormControlLabel, {
105
110
  value: "public",
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, useCallback, useRef, useMemo } 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, Chip } from "@mui/material";
3
+ import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar, Chip, TextField, InputAdornment, FormControl, InputLabel, Select, Divider } 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";
@@ -27,6 +27,8 @@ 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
29
  import DifficultyIcon from "@mui/icons-material/School";
30
+ import Close from "@mui/icons-material/Close";
31
+ import SearchOff from "@mui/icons-material/SearchOff";
30
32
  import ColorLinearProgress from "../../Misc/ColorLinearProgress/ColorLinearProgress";
31
33
  import { calcCompletions, calcPercentageCompletion } from "../../utils/index";
32
34
  import SelectClassroom from "../../Dialogs/SelectClassroom/SelectClassroom";
@@ -47,6 +49,56 @@ const useStyles = makeStyles()(theme => ({
47
49
  color: theme.palette.success ? theme.palette.success.main : ""
48
50
  }
49
51
  }));
52
+ const ALL_DIFFICULTIES = "all";
53
+ const DIFFICULTY_ORDER = ["A1", "A2", "B1", "B2", "C1", "C2"];
54
+ const SEARCH_DEBOUNCE_MS = 200;
55
+ const difficultyRank = difficulty => {
56
+ const rank = DIFFICULTY_ORDER.indexOf(difficulty);
57
+ return rank < 0 ? DIFFICULTY_ORDER.length : rank;
58
+ };
59
+ const normaliseText = text => (text || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
60
+ const isSectionVisible = (section, isCreator) => !section.isHidden || isCreator;
61
+ const isTopicVisible = (topic, isCreator) => !topic.isTopicHidden || isCreator;
62
+ function filterOutline(sections, {
63
+ query,
64
+ difficulty,
65
+ isCreator
66
+ }) {
67
+ const normalisedQuery = normaliseText(query.trim());
68
+ const isDifficultyFiltered = difficulty !== ALL_DIFFICULTIES;
69
+ let topicCount = 0;
70
+ const filteredSections = [];
71
+ sections.forEach(section => {
72
+ if (!isSectionVisible(section, isCreator)) return;
73
+ const visibleTopics = (section.topics || []).filter(topic => isTopicVisible(topic, isCreator));
74
+ const isSectionMatch = !!normalisedQuery && normaliseText(section.sectionName).includes(normalisedQuery);
75
+ const topics = visibleTopics.filter(topic => (!normalisedQuery || isSectionMatch || normaliseText(topic.topicName).includes(normalisedQuery) || normaliseText(topic.description).includes(normalisedQuery)) && (!isDifficultyFiltered || topic.difficulty === difficulty));
76
+ const isKeptForSectionMatch = isSectionMatch && !isDifficultyFiltered;
77
+ if (!topics.length && !isKeptForSectionMatch) return;
78
+ topicCount += topics.length;
79
+ filteredSections.push({
80
+ ...section,
81
+ topics,
82
+ filterCount: {
83
+ shown: topics.length,
84
+ total: visibleTopics.length
85
+ }
86
+ });
87
+ });
88
+ return {
89
+ sections: filteredSections,
90
+ topicCount
91
+ };
92
+ }
93
+ const activeFilterOutlineSx = {
94
+ "& .MuiOutlinedInput-notchedOutline": {
95
+ borderColor: "primary.main",
96
+ borderWidth: 2
97
+ },
98
+ "& .MuiInputLabel-root": {
99
+ color: "primary.main"
100
+ }
101
+ };
50
102
  function OverflowMenu({
51
103
  children,
52
104
  anchorEl,
@@ -346,10 +398,12 @@ function Topic({
346
398
  courseSettings,
347
399
  showViewTopicButton,
348
400
  isClassroomArchived = false,
401
+ highlightDifficulty,
349
402
  ...otherProps
350
403
  }) {
351
404
  const theme = useTheme();
352
405
  const isLargeScreen = useMediaQuery(theme.breakpoints.up("sm"));
406
+ const isDifficultyHighlighted = !!highlightDifficulty && topic?.difficulty === highlightDifficulty;
353
407
  const [open] = useState(false);
354
408
  const [anchorEl, setAnchorEl] = useState(null);
355
409
  const handleClickMenu = event => {
@@ -571,8 +625,10 @@ function Topic({
571
625
  size: "small",
572
626
  sx: theme => ({
573
627
  height: 24,
574
- backgroundColor: theme.palette.action.selected,
575
- color: theme.palette.text.secondary,
628
+ backgroundColor: isDifficultyHighlighted ? alpha(theme.palette.primary.main, 0.08) : theme.palette.action.selected,
629
+ color: isDifficultyHighlighted ? theme.palette.primary.dark : theme.palette.text.secondary,
630
+ border: "1px solid",
631
+ borderColor: isDifficultyHighlighted ? theme.palette.primary.light : "transparent",
576
632
  "& .MuiChip-label": {
577
633
  fontSize: "0.75rem",
578
634
  px: 0.75
@@ -707,14 +763,16 @@ function Topic({
707
763
  })
708
764
  }), isCreator && /*#__PURE__*/_jsx(Tooltip, {
709
765
  title: t("move_topic"),
710
- children: /*#__PURE__*/_jsx(IconButton, {
711
- "data-handler-id": handlerId,
712
- style: {
713
- cursor: "move"
714
- },
715
- "aria-label": "Move Topic",
716
- disabled: isCreator ? false : true,
717
- children: /*#__PURE__*/_jsx(DragHandle, {})
766
+ children: /*#__PURE__*/_jsx("span", {
767
+ children: /*#__PURE__*/_jsx(IconButton, {
768
+ "data-handler-id": handlerId,
769
+ style: {
770
+ cursor: disableDrag ? "default" : "move"
771
+ },
772
+ "aria-label": "Move Topic",
773
+ disabled: !isCreator || !!disableDrag,
774
+ children: /*#__PURE__*/_jsx(DragHandle, {})
775
+ })
718
776
  })
719
777
  })]
720
778
  })]
@@ -887,6 +945,8 @@ function Section({
887
945
  coursePictureBuilderColor,
888
946
  coursePictureBuilderIcon,
889
947
  openSnackbar,
948
+ filterCount,
949
+ hideAddTopic = false,
890
950
  ...otherProps
891
951
  }) {
892
952
  const {
@@ -1068,12 +1128,26 @@ function Section({
1068
1128
  sx: {
1069
1129
  display: "flex"
1070
1130
  },
1071
- children: [/*#__PURE__*/_jsx(Box, {
1131
+ children: [/*#__PURE__*/_jsxs(Box, {
1072
1132
  sx: {
1073
1133
  fontWeight: "fontWeightBold",
1074
- flexGrow: 1
1134
+ flexGrow: 1,
1135
+ display: "flex",
1136
+ alignItems: "center",
1137
+ gap: 1.5
1075
1138
  },
1076
- children: sectionName
1139
+ children: [sectionName, filterCount && /*#__PURE__*/_jsx(Typography, {
1140
+ variant: "caption",
1141
+ color: "textSecondary",
1142
+ sx: {
1143
+ whiteSpace: "nowrap"
1144
+ },
1145
+ "data-cy": "section-filter-count",
1146
+ children: t("x_of_y", {
1147
+ shown: filterCount.shown,
1148
+ total: filterCount.total
1149
+ })
1150
+ })]
1077
1151
  }), !isLargeScreen ? /*#__PURE__*/_jsxs(_Fragment, {
1078
1152
  children: [/*#__PURE__*/_jsx(Box, {
1079
1153
  children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
@@ -1091,7 +1165,7 @@ function Section({
1091
1165
  }), /*#__PURE__*/_jsxs(OverflowMenu, {
1092
1166
  anchorEl: anchorEl,
1093
1167
  handleClose: handleCloseMenu,
1094
- children: [/*#__PURE__*/_jsxs(MenuItem, {
1168
+ children: [!hideAddTopic && /*#__PURE__*/_jsxs(MenuItem, {
1095
1169
  onClick: () => {
1096
1170
  setIsQuickCreateTopicOpen(true);
1097
1171
  handleCloseMenu();
@@ -1103,7 +1177,7 @@ function Section({
1103
1177
  }), /*#__PURE__*/_jsx(ListItemText, {
1104
1178
  primary: t("create_topic")
1105
1179
  })]
1106
- }), /*#__PURE__*/_jsxs(MenuItem, {
1180
+ }), !hideAddTopic && /*#__PURE__*/_jsxs(MenuItem, {
1107
1181
  onClick: () => {
1108
1182
  handleFindTopic();
1109
1183
  handleCloseMenu();
@@ -1156,14 +1230,16 @@ function Section({
1156
1230
  }), /*#__PURE__*/_jsx(Box, {
1157
1231
  children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
1158
1232
  title: t("move_section"),
1159
- children: /*#__PURE__*/_jsx(IconButton, {
1160
- "data-handler-id": handlerId,
1161
- style: {
1162
- cursor: "move"
1163
- },
1164
- "aria-label": "Move Section",
1165
- disabled: isCreator ? false : true,
1166
- children: /*#__PURE__*/_jsx(DragHandle, {})
1233
+ children: /*#__PURE__*/_jsx("span", {
1234
+ children: /*#__PURE__*/_jsx(IconButton, {
1235
+ "data-handler-id": handlerId,
1236
+ style: {
1237
+ cursor: disableDrag ? "default" : "move"
1238
+ },
1239
+ "aria-label": "Move Section",
1240
+ disabled: !isCreator || !!disableDrag,
1241
+ children: /*#__PURE__*/_jsx(DragHandle, {})
1242
+ })
1167
1243
  })
1168
1244
  }) : null
1169
1245
  })]
@@ -1223,14 +1299,16 @@ function Section({
1223
1299
  }), /*#__PURE__*/_jsx(Box, {
1224
1300
  children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
1225
1301
  title: t("move_section"),
1226
- children: /*#__PURE__*/_jsx(IconButton, {
1227
- "data-handler-id": handlerId,
1228
- style: {
1229
- cursor: "move"
1230
- },
1231
- "aria-label": "Move Section",
1232
- disabled: isCreator ? false : true,
1233
- children: /*#__PURE__*/_jsx(DragHandle, {})
1302
+ children: /*#__PURE__*/_jsx("span", {
1303
+ children: /*#__PURE__*/_jsx(IconButton, {
1304
+ "data-handler-id": handlerId,
1305
+ style: {
1306
+ cursor: disableDrag ? "default" : "move"
1307
+ },
1308
+ "aria-label": "Move Section",
1309
+ disabled: !isCreator || !!disableDrag,
1310
+ children: /*#__PURE__*/_jsx(DragHandle, {})
1311
+ })
1234
1312
  })
1235
1313
  }) : null
1236
1314
  })]
@@ -1257,7 +1335,7 @@ function Section({
1257
1335
  showViewTopicButton: showViewTopicButton,
1258
1336
  isClassroomArchived: isClassroomArchived,
1259
1337
  ...otherProps
1260
- }), isCreator && /*#__PURE__*/_jsxs(_Fragment, {
1338
+ }), isCreator && !hideAddTopic && /*#__PURE__*/_jsxs(_Fragment, {
1261
1339
  children: [/*#__PURE__*/_jsxs(ListItem, {
1262
1340
  button: true,
1263
1341
  onClick: handleOpenAddTopicMenu,
@@ -1469,6 +1547,7 @@ function CourseOutline({
1469
1547
  classroomId,
1470
1548
  isChallengeModeStudent = false,
1471
1549
  isReadWriteModeStudent = false,
1550
+ enableFilters = false,
1472
1551
  ...otherProps
1473
1552
  }) {
1474
1553
  const [percentageCompletion, setPercentageCompletion] = useState({
@@ -1632,6 +1711,50 @@ function CourseOutline({
1632
1711
  completed,
1633
1712
  total
1634
1713
  } = percentageCompletion;
1714
+ const [searchInput, setSearchInput] = useState("");
1715
+ const [query, setQuery] = useState("");
1716
+ const [difficulty, setDifficulty] = useState(ALL_DIFFICULTIES);
1717
+ useEffect(() => {
1718
+ const timeout = setTimeout(() => setQuery(searchInput), SEARCH_DEBOUNCE_MS);
1719
+ return () => clearTimeout(timeout);
1720
+ }, [searchInput]);
1721
+ const clearSearch = () => {
1722
+ setSearchInput("");
1723
+ setQuery("");
1724
+ };
1725
+ const clearFilters = () => {
1726
+ clearSearch();
1727
+ setDifficulty(ALL_DIFFICULTIES);
1728
+ };
1729
+ const trimmedQuery = query.trim();
1730
+ const isDifficultyFiltered = difficulty !== ALL_DIFFICULTIES;
1731
+ const isFiltering = enableFilters && (!!trimmedQuery || isDifficultyFiltered);
1732
+ const difficultyOptions = useMemo(() => {
1733
+ const difficulties = new Set();
1734
+ sects.forEach(section => {
1735
+ if (!isSectionVisible(section, isCreator)) return;
1736
+ (section.topics || []).forEach(topic => {
1737
+ if (isTopicVisible(topic, isCreator) && topic.difficulty) {
1738
+ difficulties.add(topic.difficulty);
1739
+ }
1740
+ });
1741
+ });
1742
+ if (difficulty !== ALL_DIFFICULTIES) difficulties.add(difficulty);
1743
+ return [...difficulties].sort((a, b) => difficultyRank(a) - difficultyRank(b) || a.localeCompare(b));
1744
+ }, [sects, isCreator, difficulty]);
1745
+ const filtered = useMemo(() => isFiltering ? filterOutline(sects, {
1746
+ query: trimmedQuery,
1747
+ difficulty,
1748
+ isCreator
1749
+ }) : null, [isFiltering, sects, trimmedQuery, difficulty, isCreator]);
1750
+ const filterCountText = filtered ? t("outline_filter_count", {
1751
+ topics: t("topic_count", {
1752
+ count: filtered.topicCount
1753
+ }),
1754
+ sections: t("section_count", {
1755
+ count: filtered.sections.length
1756
+ })
1757
+ }) : "";
1635
1758
  if (isLoading) {
1636
1759
  return /*#__PURE__*/_jsx(_Fragment, {
1637
1760
  children: Array(3).fill("").map((_, idx) => /*#__PURE__*/_jsxs("div", {
@@ -1684,55 +1807,224 @@ function CourseOutline({
1684
1807
  }, `section_${idx}`))
1685
1808
  });
1686
1809
  }
1810
+ const isEmptyResult = !!filtered && filtered.sections.length === 0;
1811
+ const isProgressShown = isMember && !isCreator;
1687
1812
  return /*#__PURE__*/_jsxs(_Fragment, {
1688
- children: [/*#__PURE__*/_jsx(Typography, {
1689
- variant: "subtitle1",
1690
- children: /*#__PURE__*/_jsxs(Box, {
1813
+ children: [/*#__PURE__*/_jsxs(Box, {
1814
+ sx: {
1815
+ display: "flex",
1816
+ alignItems: "center",
1817
+ flexWrap: "wrap",
1818
+ gap: 2
1819
+ },
1820
+ children: [/*#__PURE__*/_jsx(Typography, {
1821
+ variant: "subtitle1",
1822
+ sx: {
1823
+ fontWeight: "fontWeightBold",
1824
+ whiteSpace: "nowrap"
1825
+ },
1826
+ children: isProgressShown ? t("course_progress") : t(isCreator || isCreator && isMember ? "course_outline" : "course_progress")
1827
+ }), isProgressShown ? /*#__PURE__*/_jsxs(Box, {
1691
1828
  sx: {
1692
1829
  display: "flex",
1693
1830
  alignItems: "center",
1694
- justifyContent: "space-between"
1831
+ flexGrow: 1,
1832
+ minWidth: 120,
1833
+ ml: 4
1695
1834
  },
1696
1835
  children: [/*#__PURE__*/_jsx(Box, {
1697
1836
  sx: {
1698
- fontWeight: "fontWeightBold"
1837
+ flexGrow: 1
1699
1838
  },
1700
- children: isMember && !isCreator ? t("course_progress") : t(isCreator || isCreator && isMember ? "course_outline" : "course_progress")
1701
- }), isMember && !isCreator && /*#__PURE__*/_jsxs(_Fragment, {
1702
- children: [/*#__PURE__*/_jsx(Box, {
1703
- sx: {
1704
- display: "inline",
1705
- flexGrow: 1,
1706
- ml: 6
1707
- },
1708
- children: /*#__PURE__*/_jsx(Tooltip, {
1709
- title: `Completed ${completed} / ${total} exercises`,
1710
- children: /*#__PURE__*/_jsx(ColorLinearProgress, {
1711
- variant: "determinate",
1712
- value: percentage
1713
- })
1839
+ children: /*#__PURE__*/_jsx(Tooltip, {
1840
+ title: `Completed ${completed} / ${total} exercises`,
1841
+ children: /*#__PURE__*/_jsx(ColorLinearProgress, {
1842
+ variant: "determinate",
1843
+ value: percentage
1714
1844
  })
1715
- }), /*#__PURE__*/_jsxs(Box, {
1716
- sx: {
1717
- ml: 1,
1718
- fontSize: 14
1845
+ })
1846
+ }), /*#__PURE__*/_jsxs(Box, {
1847
+ sx: {
1848
+ ml: 1,
1849
+ fontSize: 14
1850
+ },
1851
+ children: [percentage, "%"]
1852
+ })]
1853
+ }) : /*#__PURE__*/_jsx(Box, {
1854
+ sx: {
1855
+ flexGrow: 1
1856
+ }
1857
+ }), enableFilters && /*#__PURE__*/_jsxs(_Fragment, {
1858
+ children: [/*#__PURE__*/_jsx(TextField, {
1859
+ size: "small",
1860
+ value: searchInput,
1861
+ onChange: event => setSearchInput(event.target.value),
1862
+ onKeyDown: event => {
1863
+ if (event.key === "Escape" && searchInput) {
1864
+ event.stopPropagation();
1865
+ clearSearch();
1866
+ }
1867
+ },
1868
+ placeholder: t("search_sections_and_topics"),
1869
+ sx: {
1870
+ width: {
1871
+ xs: "100%",
1872
+ sm: 300
1873
+ },
1874
+ ...(trimmedQuery ? activeFilterOutlineSx : {})
1875
+ },
1876
+ slotProps: {
1877
+ htmlInput: {
1878
+ "aria-label": t("search_sections_and_topics"),
1879
+ "data-cy": "outline-search"
1880
+ },
1881
+ input: {
1882
+ startAdornment: /*#__PURE__*/_jsx(InputAdornment, {
1883
+ position: "start",
1884
+ children: /*#__PURE__*/_jsx(Search, {
1885
+ fontSize: "small"
1886
+ })
1887
+ }),
1888
+ endAdornment: searchInput ? /*#__PURE__*/_jsx(InputAdornment, {
1889
+ position: "end",
1890
+ children: /*#__PURE__*/_jsx(IconButton, {
1891
+ size: "small",
1892
+ edge: "end",
1893
+ "aria-label": t("clear_search"),
1894
+ onClick: clearSearch,
1895
+ children: /*#__PURE__*/_jsx(Close, {
1896
+ fontSize: "small"
1897
+ })
1898
+ })
1899
+ }) : null
1900
+ }
1901
+ }
1902
+ }), difficultyOptions.length > 0 && /*#__PURE__*/_jsxs(FormControl, {
1903
+ size: "small",
1904
+ sx: {
1905
+ width: {
1906
+ xs: "100%",
1907
+ sm: 200
1719
1908
  },
1720
- children: [percentage, "%"]
1909
+ ...(isDifficultyFiltered ? activeFilterOutlineSx : {})
1910
+ },
1911
+ children: [/*#__PURE__*/_jsx(InputLabel, {
1912
+ id: "course-outline-difficulty-label",
1913
+ children: t("difficulty")
1914
+ }), /*#__PURE__*/_jsxs(Select, {
1915
+ labelId: "course-outline-difficulty-label",
1916
+ label: t("difficulty"),
1917
+ value: difficulty,
1918
+ onChange: event => setDifficulty(event.target.value),
1919
+ "data-cy": "outline-difficulty-filter",
1920
+ children: [/*#__PURE__*/_jsx(MenuItem, {
1921
+ value: ALL_DIFFICULTIES,
1922
+ children: t("all")
1923
+ }), difficultyOptions.map(option => /*#__PURE__*/_jsx(MenuItem, {
1924
+ value: option,
1925
+ children: t(`card_${option}`)
1926
+ }, option))]
1721
1927
  })]
1722
- }), /*#__PURE__*/_jsx(Box, {
1723
- children: isCreator && /*#__PURE__*/_jsx(Button, {
1724
- variant: "contained",
1725
- color: "primary",
1726
- onClick: handleClickOpenCreateSection,
1727
- size: "small",
1728
- "data-cy": "add-section",
1729
- children: t("add_section")
1730
- })
1731
1928
  })]
1732
- })
1929
+ }), isCreator && /*#__PURE__*/_jsx(Button, {
1930
+ variant: "contained",
1931
+ color: "primary",
1932
+ onClick: handleClickOpenCreateSection,
1933
+ size: "small",
1934
+ "data-cy": "add-section",
1935
+ children: t("add_section")
1936
+ })]
1937
+ }), enableFilters && /*#__PURE__*/_jsx(Box, {
1938
+ role: "status",
1939
+ "aria-live": "polite",
1940
+ sx: {
1941
+ position: "absolute",
1942
+ width: 1,
1943
+ height: 1,
1944
+ overflow: "hidden",
1945
+ clip: "rect(0 0 0 0)",
1946
+ whiteSpace: "nowrap"
1947
+ },
1948
+ children: isEmptyResult ? t("no_sections_or_topics_match") : filterCountText
1949
+ }), isFiltering && /*#__PURE__*/_jsxs(_Fragment, {
1950
+ children: [/*#__PURE__*/_jsx(Divider, {
1951
+ sx: {
1952
+ mt: 2
1953
+ }
1954
+ }), /*#__PURE__*/_jsxs(Box, {
1955
+ sx: {
1956
+ display: "flex",
1957
+ alignItems: "center",
1958
+ flexWrap: "wrap",
1959
+ gap: 1,
1960
+ pt: 1.25,
1961
+ pb: 0.5
1962
+ },
1963
+ "data-cy": "outline-filter-bar",
1964
+ children: [/*#__PURE__*/_jsx(Typography, {
1965
+ variant: "body2",
1966
+ sx: {
1967
+ fontWeight: 600,
1968
+ mr: 0.5,
1969
+ whiteSpace: "nowrap"
1970
+ },
1971
+ children: filterCountText
1972
+ }), trimmedQuery && /*#__PURE__*/_jsx(Chip, {
1973
+ size: "small",
1974
+ icon: /*#__PURE__*/_jsx(Search, {}),
1975
+ label: `“${trimmedQuery}”`,
1976
+ onDelete: clearSearch
1977
+ }), isDifficultyFiltered && /*#__PURE__*/_jsx(Chip, {
1978
+ size: "small",
1979
+ icon: /*#__PURE__*/_jsx(DifficultyIcon, {}),
1980
+ label: t(`card_${difficulty}`),
1981
+ onDelete: () => setDifficulty(ALL_DIFFICULTIES)
1982
+ }), /*#__PURE__*/_jsx(Button, {
1983
+ size: "small",
1984
+ onClick: clearFilters,
1985
+ children: t("clear_all")
1986
+ })]
1987
+ })]
1988
+ }), isEmptyResult && /*#__PURE__*/_jsxs(Box, {
1989
+ sx: {
1990
+ display: "flex",
1991
+ flexDirection: "column",
1992
+ alignItems: "center",
1993
+ gap: 1,
1994
+ pt: 6,
1995
+ pb: 5,
1996
+ textAlign: "center"
1997
+ },
1998
+ "data-cy": "outline-empty-state",
1999
+ children: [/*#__PURE__*/_jsx(SearchOff, {
2000
+ sx: {
2001
+ fontSize: 40,
2002
+ color: "action.disabled"
2003
+ }
2004
+ }), /*#__PURE__*/_jsx(Typography, {
2005
+ variant: "subtitle1",
2006
+ sx: {
2007
+ fontWeight: "fontWeightBold"
2008
+ },
2009
+ children: t("no_sections_or_topics_match")
2010
+ }), /*#__PURE__*/_jsx(Typography, {
2011
+ variant: "body2",
2012
+ color: "textSecondary",
2013
+ children: trimmedQuery && isDifficultyFiltered ? t("nothing_matches_keyword_at_difficulty", {
2014
+ keyword: trimmedQuery,
2015
+ difficulty: t(`card_${difficulty}`)
2016
+ }) : t("try_different_keyword_or_difficulty")
2017
+ }), /*#__PURE__*/_jsx(Button, {
2018
+ variant: "outlined",
2019
+ sx: {
2020
+ mt: 1
2021
+ },
2022
+ onClick: clearFilters,
2023
+ children: t("clear_filters")
2024
+ })]
1733
2025
  }), /*#__PURE__*/_jsx(SectionList, {
1734
2026
  t: t,
1735
- sections: sects,
2027
+ sections: filtered ? filtered.sections : sects,
1736
2028
  completions: completions,
1737
2029
  isMember: isMember,
1738
2030
  courseId: courseId,
@@ -1771,7 +2063,9 @@ function CourseOutline({
1771
2063
  teachClassrooms: teachClassrooms,
1772
2064
  ...otherProps,
1773
2065
  loading: loading,
1774
- disableDrag: disableDrag,
2066
+ disableDrag: disableDrag || isFiltering,
2067
+ highlightDifficulty: isFiltering && isDifficultyFiltered ? difficulty : undefined,
2068
+ hideAddTopic: isFiltering,
1775
2069
  courseSettings: courseSettings,
1776
2070
  showViewTopicButton: showViewTopicButton,
1777
2071
  isClassroomArchived: isClassroomArchived,
@@ -1,5 +1,5 @@
1
- import { render } from "@testing-library/react";
2
- import { describe, it, expect, vi } from "vitest";
1
+ import { render, screen, fireEvent, within, waitFor, cleanup } from "@testing-library/react";
2
+ import { describe, it, expect, vi, afterEach } from "vitest";
3
3
  import CourseOutline from "./CourseOutline";
4
4
  import { props } from "./CourseOutline.stories";
5
5
  import { DndProvider } from "react-dnd";
@@ -86,4 +86,211 @@ describe("CourseOutline", () => {
86
86
  }));
87
87
  unmount();
88
88
  });
89
+ });
90
+
91
+ // Minimal i18next-like `t`: resolves `_one`/`_other` plurals and `{{var}}`
92
+ // interpolation so assertions can read real copy.
93
+ const strings = {
94
+ topic_count_one: "{{count}} topic",
95
+ topic_count_other: "{{count}} topics",
96
+ section_count_one: "{{count}} section",
97
+ section_count_other: "{{count}} sections",
98
+ outline_filter_count: "{{topics}} in {{sections}}",
99
+ x_of_y: "{{shown}} of {{total}}",
100
+ search_sections_and_topics: "Search sections and topics",
101
+ all: "All",
102
+ difficulty: "Difficulty",
103
+ clear_all: "Clear all",
104
+ clear_filters: "Clear filters",
105
+ no_sections_or_topics_match: "No sections or topics match",
106
+ try_different_keyword_or_difficulty: "Try a different keyword or difficulty.",
107
+ nothing_matches_keyword_at_difficulty: "Nothing matches “{{keyword}}” at {{difficulty}} difficulty. Try another keyword or difficulty."
108
+ };
109
+ const t = (key, options = {}) => {
110
+ const pluralKey = options.count !== undefined ? `${key}_${options.count === 1 ? "one" : "other"}` : key;
111
+ const template = strings[pluralKey] ?? strings[key] ?? key;
112
+ return template.replace(/\{\{(\w+)\}\}/g, (_, name) => options[name] ?? "");
113
+ };
114
+ const filterTopic = (id, name, description, difficulty, sectionId, idx, extra = {}) => ({
115
+ topicId: id,
116
+ topicName: name,
117
+ description,
118
+ difficulty,
119
+ sectionId,
120
+ idx,
121
+ roleplays: [],
122
+ bots: [],
123
+ completions: [],
124
+ ...extra
125
+ });
126
+ const filterProps = (overrides = {}) => ({
127
+ ...props,
128
+ t,
129
+ enableFilters: true,
130
+ isCreator: true,
131
+ isMember: false,
132
+ courseId: "course-1",
133
+ sections: [{
134
+ sectionId: "s1",
135
+ sectionName: "Unidad 1 · Identidad",
136
+ idx: 0,
137
+ topics: [filterTopic("t1", "Una canción", "Escucha la canción", "A2", "s1", 0), filterTopic("t2", "Perífrasis verbales", "Practica", "B1", "s1", 1)]
138
+ }, {
139
+ sectionId: "s2",
140
+ sectionName: "Unidad 3 · Viajes",
141
+ idx: 1,
142
+ topics: [filterTopic("t3", "En el aeropuerto", "Vocabulario del aeropuerto", "A2", "s2", 0), filterTopic("t4", "¿Adónde vas?", "Planes", "B1", "s2", 1), filterTopic("t5", "Secreto", "Vocabulario oculto", "C1", "s2", 2, {
143
+ isTopicHidden: true
144
+ })]
145
+ }],
146
+ ...overrides
147
+ });
148
+ const topicNames = container => [...container.querySelectorAll('[data-cy="topic-list-item"]')].map(item => item.querySelector(".MuiListItemText-primary").textContent);
149
+ const search = value => fireEvent.change(screen.getByLabelText("Search sections and topics"), {
150
+ target: {
151
+ value
152
+ }
153
+ });
154
+ const selectDifficulty = label => {
155
+ fireEvent.mouseDown(screen.getByRole("combobox", {
156
+ name: "Difficulty"
157
+ }));
158
+ fireEvent.click(within(screen.getByRole("listbox")).getByText(label));
159
+ };
160
+ describe("CourseOutline filters", () => {
161
+ afterEach(cleanup);
162
+ it("does not render the filters unless enableFilters is set", () => {
163
+ renderOutline(filterProps({
164
+ enableFilters: false
165
+ }));
166
+ expect(screen.queryByLabelText("Search sections and topics")).toBeNull();
167
+ });
168
+ it("only offers difficulties that exist in the course, in CEFR order", () => {
169
+ renderOutline(filterProps({
170
+ isCreator: false,
171
+ isMember: false
172
+ }));
173
+ fireEvent.mouseDown(screen.getByRole("combobox", {
174
+ name: "Difficulty"
175
+ }));
176
+ const options = within(screen.getByRole("listbox")).getAllByRole("option").map(option => option.textContent);
177
+ // C1 only exists on a hidden topic, so a non-creator never sees it.
178
+ expect(options).toEqual(["All", "card_A2", "card_B1"]);
179
+ });
180
+ it("keeps every topic of a section whose name matches the keyword", async () => {
181
+ const {
182
+ container
183
+ } = renderOutline(filterProps());
184
+ search("viaje");
185
+ await waitFor(() => expect(topicNames(container)).toEqual(["En el aeropuerto", "¿Adónde vas?", "Secreto"]));
186
+ expect(container.querySelector('[data-cy="outline-filter-bar"]').textContent).toContain("3 topics in 1 section");
187
+ expect(screen.getByText("3 of 3")).toBeTruthy();
188
+ });
189
+ it("keeps a section whose name matches even when it has no visible topics", async () => {
190
+ const base = filterProps();
191
+ const sections = [...base.sections, {
192
+ sectionId: "s3",
193
+ sectionName: "Unidad 4 · Repaso",
194
+ idx: 2,
195
+ topics: []
196
+ }, {
197
+ sectionId: "s4",
198
+ sectionName: "Unidad 5 · Repaso final",
199
+ idx: 3,
200
+ topics: [filterTopic("t6", "Examen", "Oculto", "A2", "s4", 0, {
201
+ isTopicHidden: true
202
+ })]
203
+ }];
204
+ // Students cannot see t6, so both matching sections have no visible topics.
205
+ const {
206
+ container
207
+ } = renderOutline(filterProps({
208
+ sections,
209
+ isCreator: false,
210
+ isMember: false
211
+ }));
212
+ search("repaso");
213
+ await waitFor(() => expect(container.querySelectorAll('[data-cy="section-drop-zone"]')).toHaveLength(2));
214
+ expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeNull();
215
+ expect(container.querySelector('[data-cy="outline-filter-bar"]').textContent).toContain("0 topics in 2 sections");
216
+ expect(screen.getAllByText("0 of 0")).toHaveLength(2);
217
+
218
+ // Difficulty only applies to topics, so with one selected the sections
219
+ // have nothing that matches.
220
+ selectDifficulty("card_A2");
221
+ expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeTruthy();
222
+ });
223
+ it("matches topic descriptions and ignores accents", async () => {
224
+ const {
225
+ container
226
+ } = renderOutline(filterProps());
227
+ search("cancion");
228
+ await waitFor(() => expect(topicNames(container)).toEqual(["Una canción"]));
229
+ expect(screen.getByText("1 of 2")).toBeTruthy();
230
+ });
231
+ it("filters by difficulty across sections and combines with the keyword", async () => {
232
+ const {
233
+ container
234
+ } = renderOutline(filterProps());
235
+ selectDifficulty("card_A2");
236
+ expect(topicNames(container)).toEqual(["Una canción", "En el aeropuerto"]);
237
+ search("vocabulario");
238
+ await waitFor(() => expect(topicNames(container)).toEqual(["En el aeropuerto"]));
239
+ });
240
+ it("shows an empty state that clears both filters", async () => {
241
+ const {
242
+ container
243
+ } = renderOutline(filterProps());
244
+ selectDifficulty("card_B1");
245
+ search("subjuntivo");
246
+ await waitFor(() => expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeTruthy());
247
+ expect(screen.getByText("Nothing matches “subjuntivo” at card_B1 difficulty. Try another keyword or difficulty.")).toBeTruthy();
248
+ fireEvent.click(screen.getByRole("button", {
249
+ name: "Clear filters"
250
+ }));
251
+ expect(topicNames(container)).toHaveLength(5);
252
+ expect(container.querySelector('[data-cy="outline-filter-bar"]')).toBeNull();
253
+ });
254
+ it("clears the keyword on Escape and through its chip", async () => {
255
+ const {
256
+ container
257
+ } = renderOutline(filterProps());
258
+ const input = screen.getByLabelText("Search sections and topics");
259
+ search("viaje");
260
+ await waitFor(() => expect(topicNames(container)).toHaveLength(3));
261
+ fireEvent.keyDown(input, {
262
+ key: "Escape"
263
+ });
264
+ expect(input.value).toBe("");
265
+ expect(topicNames(container)).toHaveLength(5);
266
+ search("viaje");
267
+ const chip = await screen.findByText("“viaje”");
268
+ fireEvent.click(chip.closest(".MuiChip-root").querySelector(".MuiChip-deleteIcon"));
269
+ expect(topicNames(container)).toHaveLength(5);
270
+ });
271
+ it("hides each section's Add new topic button while filtering", async () => {
272
+ const {
273
+ container
274
+ } = renderOutline(filterProps());
275
+ const addTopicButtons = () => container.querySelectorAll('[data-cy="create-topic"]');
276
+ expect(addTopicButtons()).toHaveLength(2);
277
+ search("viaje");
278
+ await waitFor(() => expect(addTopicButtons()).toHaveLength(0));
279
+ fireEvent.keyDown(screen.getByLabelText("Search sections and topics"), {
280
+ key: "Escape"
281
+ });
282
+ expect(addTopicButtons()).toHaveLength(2);
283
+ });
284
+ it("disables the drag handles while filtering", async () => {
285
+ renderOutline(filterProps());
286
+ const moveTopicButtons = () => screen.getAllByRole("button", {
287
+ name: "Move Topic"
288
+ });
289
+ expect(moveTopicButtons().every(button => !button.disabled)).toBe(true);
290
+ selectDifficulty("card_B1");
291
+ expect(moveTopicButtons().every(button => button.disabled)).toBe(true);
292
+ expect(screen.getAllByRole("button", {
293
+ name: "Move Section"
294
+ }).every(button => button.disabled)).toBe(true);
295
+ });
89
296
  });
@@ -10,6 +10,8 @@ import ArrowForward from "@mui/icons-material/ArrowForward";
10
10
  import AutoStories from "@mui/icons-material/AutoStories";
11
11
  import SpaceBar from "@mui/icons-material/SpaceBar";
12
12
  import Headphones from "@mui/icons-material/Headphones";
13
+ import Extension from "@mui/icons-material/Extension";
14
+ import EditNote from "@mui/icons-material/EditNote";
13
15
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
14
16
  const styles = () => ({
15
17
  inline: {
@@ -103,6 +105,19 @@ function Icon({
103
105
  color: "primary"
104
106
  });
105
107
  }
108
+ if (exercise === "meaning") {
109
+ return /*#__PURE__*/_jsx(Extension, {
110
+ color: "primary"
111
+ });
112
+ }
113
+ if (exercise === "meaning-listen-and-speak") {
114
+ return /*#__PURE__*/_jsx(ActItOutListeningSpeaking, {});
115
+ }
116
+ if (exercise === "meaning-read-and-write") {
117
+ return /*#__PURE__*/_jsx(EditNote, {
118
+ color: "primary"
119
+ });
120
+ }
106
121
  return null;
107
122
  }
108
123
  function EventItem({
@@ -191,7 +191,7 @@ function AnswerAttempt({
191
191
  children: attempt.type
192
192
  })
193
193
  })]
194
- }), !["pronunciation", "meaning", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
194
+ }), !["pronunciation", "meaning", "meaning-listen-and-speak", "meaning-read-and-write", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
195
195
  align: "left",
196
196
  children: /*#__PURE__*/_jsx(Typography, {
197
197
  children: t(attempt.method)
@@ -287,7 +287,7 @@ function Attempt({
287
287
  children: t("type")
288
288
  })
289
289
  })]
290
- }), !["pronunciation", "meaning", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
290
+ }), !["pronunciation", "meaning", "meaning-listen-and-speak", "meaning-read-and-write", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
291
291
  align: "left",
292
292
  children: /*#__PURE__*/_jsx("b", {
293
293
  children: t("method")
@@ -905,7 +905,8 @@ export default function useExerciseState({
905
905
  setInAnswerOptions(false);
906
906
  handleMakeAttempt(option, "option");
907
907
  };
908
- const handleCorrectQuestionAnswer = (question, answer) => {
908
+ const handleCorrectQuestionAnswer = (question, answer, answers = []) => {
909
+ const correctAnswerText = answer?.correctAnswer ?? answers.find(a => a?.correct)?.text;
909
910
  // question time over
910
911
  let updatedAnswerAttempts = [];
911
912
  let method = "";
@@ -916,7 +917,7 @@ export default function useExerciseState({
916
917
  question,
917
918
  attempt: answer.text,
918
919
  result: "correct",
919
- answer: answer.correctAnswer,
920
+ answer: correctAnswerText,
920
921
  method
921
922
  }];
922
923
  } else {
@@ -925,7 +926,7 @@ export default function useExerciseState({
925
926
  question,
926
927
  attempt: answer.text,
927
928
  result: "correct",
928
- answer: answer.correct,
929
+ answer: correctAnswerText,
929
930
  method: null
930
931
  }];
931
932
  }
@@ -942,7 +943,8 @@ export default function useExerciseState({
942
943
  }
943
944
  }
944
945
  };
945
- const handleIncorrectQuestionAnswer = (question, answer) => {
946
+ const handleIncorrectQuestionAnswer = (question, answer, answers = []) => {
947
+ const correctAnswerText = answer?.correctAnswer ?? answers.find(a => a?.correct)?.text;
946
948
  let method = "";
947
949
  let updatedAnswerAttempts = [];
948
950
  if (gameId === "fill-in-the-blanks" || gameId === "story") {
@@ -952,7 +954,7 @@ export default function useExerciseState({
952
954
  question,
953
955
  attempt: answer.text,
954
956
  result: "incorrect",
955
- answer: answer.correctAnswer,
957
+ answer: correctAnswerText,
956
958
  method
957
959
  }];
958
960
  } else {
@@ -961,7 +963,7 @@ export default function useExerciseState({
961
963
  question,
962
964
  attempt: answer.text,
963
965
  result: "incorrect",
964
- answer: answer.correct,
966
+ answer: correctAnswerText,
965
967
  method: null
966
968
  }];
967
969
  }
@@ -109,6 +109,13 @@ export default function useRecognition(props = {}) {
109
109
  // Guards against a second click landing during the async start-up window,
110
110
  // before recognizing/isRecognizingDisabled state has re-rendered the button.
111
111
  const isStartingRef = useRef(false);
112
+ // stopRecording()'s delayed closeAll(). Replaced, not stacked, so a second
113
+ // stop() can't shut down a recording that started in between.
114
+ const scheduledStopRecordingRef = useRef(null);
115
+ // This hook's own handlers on the shared socket, so cleanup removes only
116
+ // these and never another mounted hook's.
117
+ const speechDataListenerRef = useRef(null);
118
+ const serverErrorListenerRef = useRef(null);
112
119
 
113
120
  // Lets us query, e.g., how often students end up on the polling fallback
114
121
  // (a proxy for "couldn't connect via websocket") or hit connect errors.
@@ -243,11 +250,20 @@ export default function useRecognition(props = {}) {
243
250
  * worklet module (a source of noticeable start-up lag). Pass fullClose
244
251
  * when the hook itself is unmounting and the context should be released.
245
252
  */
253
+ function removeSocketListeners() {
254
+ if (speechDataListenerRef.current) {
255
+ socket.off("speechData", speechDataListenerRef.current);
256
+ speechDataListenerRef.current = null;
257
+ }
258
+ if (serverErrorListenerRef.current) {
259
+ socket.off("serverError", serverErrorListenerRef.current);
260
+ serverErrorListenerRef.current = null;
261
+ }
262
+ }
246
263
  function closeAll(keepSocketAlive, fullClose = false) {
247
264
  // Clear the listeners (prevents issue if opening and closing repeatedly)
248
265
  if (keepSocketAlive) {
249
- socket.off("speechData");
250
- socket.off("serverError");
266
+ removeSocketListeners();
251
267
  }
252
268
  let tracks = streamRef.current ? streamRef.current.getTracks() : null;
253
269
  if (tracks && Array.isArray(tracks)) {
@@ -302,6 +318,9 @@ export default function useRecognition(props = {}) {
302
318
  * the mic has been quiet for `silenceMs`. `{ silenceMs: number }`.
303
319
  */
304
320
  const initRecording = async (gcloudParams, onData, onError, autoStopOptions) => {
321
+ // When speechData last brought back words for this recording - arms the
322
+ // silence auto-stop below.
323
+ let lastTranscriptAt = null;
305
324
  try {
306
325
  await setupAudioContext();
307
326
  const handleSuccess = async function (stream) {
@@ -380,15 +399,14 @@ export default function useRecognition(props = {}) {
380
399
  const firstAudioFrameTimeoutId = setTimeout(resolveFirstAudioFrame, 800);
381
400
 
382
401
  // Silence auto-stop: each buffer's RMS volume says whether the mic
383
- // heard actual speech just now. `hasHeardSpeech` waits for at least
384
- // one loud-enough buffer before arming the timer, so someone taking
385
- // a moment to start talking doesn't get cut off before they begin;
386
- // after that, `silenceMs` of nothing but quiet buffers auto-stops
387
- // the recording exactly like clicking the mic button would.
402
+ // heard sound just now. The timer only arms once speechData has
403
+ // returned words - a loud buffer alone can be the tail of the bot's
404
+ // audio or the mic opening, which would cut the student off before
405
+ // they begin. After that, `silenceMs` with no loud buffers and no new
406
+ // words auto-stops the recording exactly like clicking the mic would.
388
407
  const silenceMs = autoStopOptions?.silenceMs;
389
408
  const SILENCE_RMS_THRESHOLD = 0.02;
390
409
  let lastLoudAt = Date.now();
391
- let hasHeardSpeech = false;
392
410
  let hasAutoStopped = false;
393
411
  processorRef.current.port.onmessage = event => {
394
412
  clearTimeout(firstAudioFrameTimeoutId);
@@ -405,8 +423,7 @@ export default function useRecognition(props = {}) {
405
423
  if (silenceMs) {
406
424
  if (rms > SILENCE_RMS_THRESHOLD) {
407
425
  lastLoudAt = Date.now();
408
- hasHeardSpeech = true;
409
- } else if (hasHeardSpeech && !hasAutoStopped && Date.now() - lastLoudAt > silenceMs) {
426
+ } else if (lastTranscriptAt && !hasAutoStopped && Date.now() - Math.max(lastLoudAt, lastTranscriptAt) > silenceMs) {
410
427
  hasAutoStopped = true;
411
428
  stop();
412
429
  }
@@ -506,19 +523,26 @@ export default function useRecognition(props = {}) {
506
523
  recognitionAlert("Speech recognition not supported, please try another browser.");
507
524
  }
508
525
 
509
- // Bind the data handler callback
526
+ // Bind the data handler callback. A failed getUserMedia leaves the last
527
+ // attempt's handlers attached, so drop them before replacing the refs.
528
+ removeSocketListeners();
510
529
  if (onData) {
511
- socket.on("speechData", data => {
530
+ speechDataListenerRef.current = data => {
531
+ if (data?.results?.some(result => result?.alternatives?.[0]?.transcript?.trim())) {
532
+ lastTranscriptAt = Date.now();
533
+ }
512
534
  onData(data);
513
- });
535
+ };
536
+ socket.on("speechData", speechDataListenerRef.current);
514
537
  }
515
- socket.on("serverError", error => {
538
+ serverErrorListenerRef.current = error => {
516
539
  console.error(`There was an issue on the server`, error);
517
540
  serverErrorRef.current = true;
518
541
  if (onError) {
519
542
  onError(error);
520
543
  }
521
- });
544
+ };
545
+ socket.on("serverError", serverErrorListenerRef.current);
522
546
  } catch (error) {
523
547
  isStartingRef.current = false;
524
548
  setRecognitionState(prevState => ({
@@ -568,23 +592,48 @@ export default function useRecognition(props = {}) {
568
592
  if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
569
593
  mediaRecorderRef.current.stop();
570
594
  }
571
- setTimeout(() => {
572
- closeAll(keepSocketAlive, fullClose);
595
+ const scheduledStopRecording = scheduledStopRecordingRef.current;
596
+ if (scheduledStopRecording) {
597
+ clearTimeout(scheduledStopRecording.timeoutId);
598
+ }
599
+ // Carry over what the cancelled closeAll() would have done: its listener
600
+ // removal, and its no-transcript check against its own transcripts.
601
+ const shouldRemoveListeners = keepSocketAlive || scheduledStopRecording?.shouldRemoveListeners;
602
+ const {
603
+ finalTranscript,
604
+ interimTranscript
605
+ } = recognitionState;
606
+ const shouldReportNoTranscript = scheduledStopRecording?.shouldReportNoTranscript || triggerEvent && (!finalTranscript || finalTranscript === "") && (!interimTranscript || interimTranscript === "");
607
+ const timeoutId = setTimeout(() => {
608
+ scheduledStopRecordingRef.current = null;
609
+ closeAll(shouldRemoveListeners, fullClose);
573
610
  setRecognitionState(prevState => ({
574
611
  ...prevState,
575
612
  isRecognizingDisabled: false
576
613
  }));
577
- const {
578
- finalTranscript,
579
- interimTranscript
580
- } = recognitionState;
581
- if (triggerEvent && (!finalTranscript || finalTranscript === "") && (!interimTranscript || interimTranscript === "")) {
614
+ if (shouldReportNoTranscript) {
582
615
  captureSpeechRecognitionNoTranscript().catch(console.error);
583
616
  }
584
617
  }, 2000);
618
+ scheduledStopRecordingRef.current = {
619
+ timeoutId,
620
+ shouldRemoveListeners,
621
+ shouldReportNoTranscript
622
+ };
585
623
  };
586
624
  useEffect(() => {
587
625
  return () => {
626
+ // Finish a pending stop now rather than on a timer that outlives this
627
+ // hook, and drop this hook's listeners while we still can.
628
+ const scheduledStopRecording = scheduledStopRecordingRef.current;
629
+ if (scheduledStopRecording) {
630
+ clearTimeout(scheduledStopRecording.timeoutId);
631
+ scheduledStopRecordingRef.current = null;
632
+ if (scheduledStopRecording.shouldReportNoTranscript) {
633
+ captureSpeechRecognitionNoTranscript().catch(console.error);
634
+ }
635
+ }
636
+ removeSocketListeners();
588
637
  stopRecording(false, false, true);
589
638
  };
590
639
  }, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1436",
3
+ "version": "0.1.1437",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [