diff --git a/backend/src/db/daos/userDao.js b/backend/src/db/daos/userDao.js index 83e654702..8867a4882 100644 --- a/backend/src/db/daos/userDao.js +++ b/backend/src/db/daos/userDao.js @@ -110,3 +110,36 @@ export const setUserProperties = async (userId, scenarioId, properties) => { user.stateVersions.get(scenarioId), ]; }; + +/** + * Retrieves the ids of resources a user has seen in a scenario. + * + * @param {string} uid - Firebase user ID. + * @param {string} scenarioId - Scenario ID the "seen" list belongs to. + * @returns {Promise} "Seen" resource IDs (empty = none). + */ +export const getSeenResources = async (uid, scenarioId) => { + const user = await User.findOne({ uid }, { seenResources: 1 }); + if (!user) throw new HttpError("user not found", 404); + return user.seenResources.get(scenarioId) ?? []; +}; + +/** + * Atomically add seen resource IDs in a scenario. + * + * @param {string} uid - Firebase user ID. + * @param {string} scenarioId - Scenario ID. + * @param {string[]} resourceIds - Resource IDs to mark as seen. + * @returns {Promise} Persisted seen resource IDs. + */ +export const addSeenResources = async (uid, scenarioId, resourceIds) => { + const user = await User.findOneAndUpdate( + { uid }, + { $addToSet: { [`seenResources.${scenarioId}`]: { $each: resourceIds } } }, + { new: true, projection: { seenResources: 1 } } + ); + + if (!user) throw new HttpError("user not found", 404); + + return user.seenResources.get(scenarioId) ?? []; +}; diff --git a/backend/src/db/models/user.js b/backend/src/db/models/user.js index ce644d9c6..feb96f2bc 100644 --- a/backend/src/db/models/user.js +++ b/backend/src/db/models/user.js @@ -48,6 +48,12 @@ const userSchema = new Schema({ of: { type: Number, default: 0 }, default: {}, }, + // Map of scenarioId to "seen" resource ids + seenResources: { + type: Map, + of: [String], + default: {}, + }, }); const User = mongoose.model("model", userSchema, "users"); diff --git a/backend/src/routes/api/user.js b/backend/src/routes/api/user.js index d341a288e..3c429da15 100644 --- a/backend/src/routes/api/user.js +++ b/backend/src/routes/api/user.js @@ -3,6 +3,8 @@ import { retrieveUserByEmail, createUser, assignScenarioToUsers, + getSeenResources, + addSeenResources, } from "../../db/daos/userDao.js"; import User from "../../db/models/user.js"; import Group from "../../db/models/group.js"; @@ -11,6 +13,7 @@ import auth from "../../middleware/firebaseAuth.js"; import STATUS from "../../util/status.js"; import { handle, HttpError } from "../../util/error.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; +import { isValidObjectId } from "../../util/validation.js"; const router = Router(); @@ -64,6 +67,50 @@ router.get( }) ); +// get resource ids that user has seen +router.get( + "/seen-resources/:scenarioId", + handle(async (req, res) => { + const { scenarioId } = req.params; + const { uid } = req.body; + + if (!isValidObjectId(scenarioId)) { + throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST); + } + + const seenResources = await getSeenResources(uid, scenarioId); + + return res.status(STATUS.OK).json({ seenResources }); + }) +); + +// mark resource ids as seen by the user +router.patch( + "/seen-resources/:scenarioId", + handle(async (req, res) => { + const { scenarioId } = req.params; + const { uid, resourceIds } = req.body; + + if (!isValidObjectId(scenarioId)) { + throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST); + } + + if ( + !Array.isArray(resourceIds) || + !resourceIds.every((id) => typeof id === "string" && isValidObjectId(id)) + ) { + throw new HttpError( + "Resource IDs must be a valid ID string array", + STATUS.BAD_REQUEST + ); + } + + const seenResources = await addSeenResources(uid, scenarioId, resourceIds); + + return res.status(STATUS.OK).json({ seenResources }); + }) +); + // NOTE: not currently used, but associated ui functionality will be added // assign scenario to users diff --git a/frontend/src/features/playScenario/PlayScenarioPage.jsx b/frontend/src/features/playScenario/PlayScenarioPage.jsx index 4415861e5..c0b59bc66 100644 --- a/frontend/src/features/playScenario/PlayScenarioPage.jsx +++ b/frontend/src/features/playScenario/PlayScenarioPage.jsx @@ -19,6 +19,7 @@ import { VolumeOffIcon, } from "lucide-react"; import ResourcesPanel from "../resources/ResourcesOverlay"; +import { usePlayerResources } from "../resources/usePlayerResources"; const sceneCache = new Map(); @@ -172,6 +173,8 @@ export default function PlayScenarioPage({ group }) { const audioRefs = useRef([]); const currScene = sceneCache.get(sceneId); + const playerResources = usePlayerResources(properties, Boolean(currScene)); + const newResourceCount = playerResources.unseenIds.length; const handleError = async (error) => { if (!error) return; @@ -478,12 +481,21 @@ export default function PlayScenarioPage({ group }) { )}
@@ -501,8 +513,7 @@ export default function PlayScenarioPage({ group }) { /> )} setResourcesOpen(false)} /> diff --git a/frontend/src/features/resources/ResourceTree.jsx b/frontend/src/features/resources/ResourceTree.jsx index 6f7378b69..5dca3b1b7 100644 --- a/frontend/src/features/resources/ResourceTree.jsx +++ b/frontend/src/features/resources/ResourceTree.jsx @@ -1,10 +1,17 @@ import React from "react"; +function NewBadge() { + return New; +} + export default function ResourceTree({ tree, + unseenIds, selectedResourceId, - setSelectedResourceId, + onSelect, }) { + const unseen = new Set(unseenIds); + return ( <> {tree.map((resource) => ( @@ -15,6 +22,9 @@ export default function ResourceTree({ {resource.name} + {resource.children.some((child) => unseen.has(child._id)) && ( + + )}
    @@ -28,11 +38,12 @@ export default function ResourceTree({ > @@ -45,11 +56,12 @@ export default function ResourceTree({ > )} diff --git a/frontend/src/features/resources/ResourcesOverlay.jsx b/frontend/src/features/resources/ResourcesOverlay.jsx index a5b2cd3a4..122def639 100644 --- a/frontend/src/features/resources/ResourcesOverlay.jsx +++ b/frontend/src/features/resources/ResourcesOverlay.jsx @@ -1,10 +1,7 @@ import React, { useEffect, useState } from "react"; import ResourceTree from "./ResourceTree"; import { FileTextIcon, SearchIcon, XIcon } from "lucide-react"; -import { useResources } from "./useResources"; -import { findById } from "../../util/search"; -import { buildResourceTree, filterTreeBySearch, normaliseFile } from "./util"; -import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; +import { filterTreeBySearch, flattenFiles } from "./util"; import SkeletonBody from "./ResourcesSkeleton"; import ResourcePreview from "./ResourcePreview"; import PanelOverlay from "../../components/PanelOverlay"; @@ -13,9 +10,16 @@ import ResourceTitle from "./components/ResourceTitle"; // NOTE: property filters can't change while the resources panel is // open, so deselecting on resource hiding isn't a concern -export default function ResourcesPanel({ properties, open, onClose }) { - const { resourcesQuery } = useResources(); - +export default function ResourcesPanel({ + tree, + unseenIds, + markSeen, + isLoading, + isError, + error, + open, + onClose, +}) { const [selectedResourceId, setSelectedResourceId] = useState(null); const [search, setSearch] = useState(""); @@ -29,20 +33,15 @@ export default function ResourcesPanel({ properties, open, onClose }) { return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); - const { data, isLoading, isError, error } = resourcesQuery; - - const foundResource = findById(data, selectedResourceId); - const selectedResource = foundResource ? normaliseFile(foundResource) : null; - - // NOTE: the filtering by properties should ideally be done on the - // server to prevent cheating, but here we filter before rendering - - const resourceTree = buildResourceTree(data ?? []); - const conditionFilteredTree = filterTreeByConditions( - resourceTree, - properties + const selectedResource = flattenFiles(tree).find( + (resource) => resource._id === selectedResourceId ); - const filteredTree = filterTreeBySearch(conditionFilteredTree, search); + const filteredTree = filterTreeBySearch(tree, search); + + const selectResource = (resourceId) => { + setSelectedResourceId(resourceId); + markSeen(resourceId); + }; return ( <> @@ -81,7 +80,7 @@ export default function ResourcesPanel({ properties, open, onClose }) { {error.message} - ) : !conditionFilteredTree?.length ? ( + ) : !tree.length ? (

    @@ -117,8 +116,9 @@ export default function ResourcesPanel({ properties, open, onClose }) { )}

diff --git a/frontend/src/features/resources/usePlayerResources.js b/frontend/src/features/resources/usePlayerResources.js new file mode 100644 index 000000000..0008655d1 --- /dev/null +++ b/frontend/src/features/resources/usePlayerResources.js @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; +import toast from "react-hot-toast"; +import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator"; +import { buildResourceTree, flattenFiles } from "./util"; +import { useResources } from "./useResources"; +import { useSeenResources } from "./useSeenResources"; + +export function usePlayerResources(properties, enabled) { + const { resourcesQuery } = useResources(); + const { seenIds, isLoaded, markSeen } = useSeenResources(); + const announcedIds = useRef(new Set()); + const ready = enabled && isLoaded && resourcesQuery.isSuccess; + + // NOTE: the filtering by properties should ideally be done on the + // server to prevent cheating, but here we filter before rendering + const tree = filterTreeByConditions( + buildResourceTree(resourcesQuery.data ?? []), + properties + ); + const seen = new Set(seenIds); + const unseenIds = ready + ? flattenFiles(tree) + .map((resource) => resource._id) + .filter((id) => !seen.has(id)) + : []; + + // toast unseen resources once a session + useEffect(() => { + const fresh = unseenIds.filter((id) => !announcedIds.current.has(id)); + fresh.forEach((id) => announcedIds.current.add(id)); + + const count = fresh.length; + if (count > 0) { + toast( + `You have ${count} new resource${count === 1 ? "" : "s"} available` + ); + } + }, [unseenIds]); + + const { isLoading, isError, error } = resourcesQuery; + + return { tree, unseenIds, markSeen, isLoading, isError, error }; +} diff --git a/frontend/src/features/resources/useSeenResources.js b/frontend/src/features/resources/useSeenResources.js new file mode 100644 index 000000000..afa83b136 --- /dev/null +++ b/frontend/src/features/resources/useSeenResources.js @@ -0,0 +1,71 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useContext, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import toast from "react-hot-toast"; +import AuthenticationContext from "../../context/AuthenticationContext"; +import { api, handleGeneric } from "../../util/api"; + +async function getSeenResources(user, scenarioId) { + const res = await api.get(user, `/api/user/seen-resources/${scenarioId}`); + return res.data.seenResources; +} + +async function addSeenResources(user, scenarioId, resourceIds) { + const res = await api.patch(user, `/api/user/seen-resources/${scenarioId}`, { + resourceIds, + }); + return res.data.seenResources; +} + +export function useSeenResources() { + const { scenarioId } = useParams(); + const { user } = useContext(AuthenticationContext); + const queryClient = useQueryClient(); + const seenKey = ["seenResources", user.uid, scenarioId]; + + //ids seen this session separate from fetched history, merged on read + //this way a fetch that started before a save cannot drop it + const [markedIds, setMarkedIds] = useState([]); + + const seenQuery = useQuery({ + queryKey: seenKey, + queryFn: () => getSeenResources(user, scenarioId), + onError: (e) => { + console.error(e); + toast.error("Couldn't load new resource notifications"); + }, + }); + + const { mutate } = useMutation({ + mutationFn: (resourceId) => + addSeenResources(user, scenarioId, [resourceId]), + onSuccess: async (serverSeen) => { + await queryClient.cancelQueries({ queryKey: seenKey }); + queryClient.setQueryData(seenKey, (old) => [ + ...new Set([...(old ?? []), ...serverSeen]), + ]); + }, + onError: (e, resourceId) => { + setMarkedIds((prev) => prev.filter((id) => id !== resourceId)); + handleGeneric(e); + }, + }); + + const seenIds = useMemo( + () => [...new Set([...(seenQuery.data ?? []), ...markedIds])], + [seenQuery.data, markedIds] + ); + + const markSeen = (resourceId) => { + if (seenIds.includes(resourceId)) return; + setMarkedIds((prev) => [...prev, resourceId]); + mutate(resourceId); + }; + + return { + seenIds, + //a failed refetch keeps the last history, so don't hide indicators + isLoaded: seenQuery.data !== undefined, + markSeen, + }; +} diff --git a/frontend/src/features/resources/util.js b/frontend/src/features/resources/util.js index 67ca761f2..6cf576e28 100644 --- a/frontend/src/features/resources/util.js +++ b/frontend/src/features/resources/util.js @@ -26,6 +26,12 @@ export function normaliseFile(resource) { }; } +export function flattenFiles(tree) { + return tree.flatMap((resource) => + resource.type === "collection" ? resource.children : [resource] + ); +} + export function filterTreeBySearch(tree, string) { const q = string.trim()?.toLowerCase(); if (!q) return tree; diff --git a/frontend/src/index.css b/frontend/src/index.css index e3701810a..8760bbcaa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,6 +1,16 @@ @import "tailwindcss"; @plugin "daisyui"; +@keyframes side-panel-slide-in { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + @theme { --font-ibm: "IBMPlexSans", sans-serif; --font-dm: "DMSans", sans-serif;