Merge branch 'messages_overhaul' into dev_environment

This commit is contained in:
Carter 2026-02-25 15:20:16 -06:00
commit f385842208
15 changed files with 278 additions and 76 deletions

View file

@ -12,7 +12,7 @@ import { useTeamAPI } from "providers";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => ({ const useStyles = makeStyles<Theme>((theme: Theme) => ({
button: { button: {
color: getSignatureAccentColour(), color: getSignatureAccentColour(),
width: theme.spacing(6), width: theme.spacing(6),
@ -70,8 +70,8 @@ export default function HeaderButtons(props: Props) {
setHasNewChats(true); setHasNewChats(true);
return; return;
} }
console.log(team.preferences.chatViewedTimestamp) // console.log(team.preferences.chatViewedTimestamp)
console.log(team.settings.lastChatTimestamp) // console.log(team.settings.lastChatTimestamp)
let viewMoment = moment(team.preferences.chatViewedTimestamp); let viewMoment = moment(team.preferences.chatViewedTimestamp);
let chatMoment = moment(team.settings.lastChatTimestamp); let chatMoment = moment(team.settings.lastChatTimestamp);
if (viewMoment.diff(chatMoment) < 0) { if (viewMoment.diff(chatMoment) < 0) {

View file

@ -14,6 +14,7 @@ import { AppThemeProvider } from 'theme/AppThemeProvider'
import HTTPProvider from 'providers/http' import HTTPProvider from 'providers/http'
import { Crisp } from "crisp-sdk-web"; import { Crisp } from "crisp-sdk-web";
import { useMobile, useSnackbar } from 'hooks' import { useMobile, useSnackbar } from 'hooks'
import { initCrisp } from '../chat/CrispChat'
// import FirmwareLoader from './FirmwareLoader' // import FirmwareLoader from './FirmwareLoader'
const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => {
@ -113,20 +114,14 @@ export default function UserWrapper(props: Props) {
}, [setGlobal]) }, [setGlobal])
useEffect(() => { useEffect(() => {
if (!crispInitialized.current && global?.user) { if (global?.user) {
Crisp.configure(import.meta.env.VITE_CRISP_WEBSITE_ID); initCrisp({
crispInitialized.current = true; websiteId: import.meta.env.VITE_CRISP_WEBSITE_ID,
Crisp.session.reset(); email: global.user.settings.email,
Crisp.user.setEmail(global?.user.settings.email); nickname: global.user.settings.name || global.user.settings.email,
// Optional: Set nickname or other info phone: global.user.settings.phoneNumber,
Crisp.user.setNickname(global?.user.settings.name || global?.user.settings.email); tokenId: global.user.id(),
Crisp.user.setPhone(global?.user.settings.phoneNumber); });
// For extra security (marks email as verified, prevents spoofing):
// Crisp.user.setEmail(user.email, "your-hmac-signature-here"); // See Crisp docs for signing
// Optional: For session continuity across devices/browsers
Crisp.setTokenId(global?.user.id()); // e.g., your DB user ID
} }
}, [global]); }, [global]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -37,6 +37,7 @@ export default function Chat(props: Props) {
const loadChats = () => { const loadChats = () => {
setLoading(true) setLoading(true)
// console.log("listing chats?")
noteAPI.listChats(10, chats.length, "desc", "timestamp", objectKey).then(resp => { noteAPI.listChats(10, chats.length, "desc", "timestamp", objectKey).then(resp => {
setChats(resp.data.chats ? resp.data.chats.reverse().concat(chats) : []) setChats(resp.data.chats ? resp.data.chats.reverse().concat(chats) : [])
setTotalMessages(resp.data.total) setTotalMessages(resp.data.total)
@ -76,33 +77,42 @@ export default function Chat(props: Props) {
}; };
useEffect(() => { useEffect(() => {
loadChats() setChats([]);
}, []); setLoaded(false);
setLoading(false);
setTotalMessages(0);
}, [objectKey]);
useEffect(() => {
if (chats.length === 0 && !loaded) {
loadChats();
}
}, [objectKey, chats, loaded]);
const loadMore = () => { const loadMore = () => {
loadChats(); loadChats();
}; };
return ( return (
<React.Fragment> <Box className={classes.chatContainer}>
<Box sx={{ flex: 1, overflowY: "hidden", minHeight: 0 }}>
{loading && !loaded ? ( {loading && !loaded ? (
<Box width="100%" height="100%" alignContent={"center"} textAlign={"center"} > <Box width="100%" height="100%" alignContent="center" textAlign="center">
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : ( ) : (
<Box className={classes.chatContainer}>
<ChatOutput <ChatOutput
totalMessages={totalMessages} totalMessages={totalMessages}
messages={chats} messages={chats}
removeNoteMethod={removeNote} removeNoteMethod={removeNote}
loadMore={loadMore} loadMore={loadMore}
/> />
<Box style={{bottom: 0}}> )}
</Box>
<Box style={{ flexShrink: 0 }}>
<Divider /> <Divider />
<ChatInput newNoteMethod={showNewNotes} objectKey={objectKey} type={type} /> <ChatInput newNoteMethod={showNewNotes} objectKey={objectKey} type={type} />
</Box> </Box>
</Box> </Box>
)}
</React.Fragment>
); );
} }

View file

@ -1,10 +1,14 @@
import { ChevronRight } from "@mui/icons-material"; import { ChevronRight } from "@mui/icons-material";
import { Avatar, Box, Drawer, IconButton, Theme, Typography } from "@mui/material"; import { Avatar, Box, Divider, Drawer, IconButton, Theme, Tooltip, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { useMobile } from "hooks"; import { useMobile } from "hooks";
import { Team } from "models"; import { Team } from "models";
import Chat from "./Chat"; import Chat from "./Chat";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { useTeamAPI } from "providers";
import { openCrispChat } from './CrispChat';
import RobotIcon from "products/CommonIcons/robotIcon";
const useStyles = makeStyles<Theme>((theme: Theme) => ({ const useStyles = makeStyles<Theme>((theme: Theme) => ({
chatDrawer: { chatDrawer: {
@ -23,8 +27,10 @@ const useStyles = makeStyles<Theme>((theme: Theme) => ({
}, },
chatDrawerMobile: { chatDrawerMobile: {
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
height: "auto", height: "100vh",
width: "100%", width: "100%",
display: "flex",
flexDirection: "column",
}, },
drawerHeader: { drawerHeader: {
display: "flex", display: "flex",
@ -44,9 +50,22 @@ interface Props {
export function ChatDrawer(props: Props) { export function ChatDrawer(props: Props) {
const { open, onClose, team } = props; const { open, onClose, team } = props;
const isMobile = useMobile(); const isMobile = useMobile();
const classes = useStyles() const classes = useStyles();
const teamAPI = useTeamAPI();
console.log(team.settings.avatar) const [selectedTeam, setSelectedTeam] = useState<Team>(team);
const [teams, setTeams] = useState<pond.Team[]>([])
useEffect(() => {
teamAPI.listTeams(10, 0, "desc", "name", undefined, false, undefined).then(resp => {
setTeams(resp.data.teams)
})
}, [teamAPI])
const openCrisp = () => {
openCrispChat()
onClose()
}
return ( return (
<Drawer <Drawer
@ -72,14 +91,41 @@ export function ChatDrawer(props: Props) {
borderRight: `1px solid ${theme.palette.divider}`, borderRight: `1px solid ${theme.palette.divider}`,
})} })}
> >
{/* Add your icons here */} <Tooltip title={team.name()} placement="right">
<IconButton > <IconButton
{/* <SomeIcon /> */} onClick={() => setSelectedTeam(team)}
sx={{
border: selectedTeam.settings?.key === team.settings.key ? "1px solid white" : "1px solid transparent",
borderRadius: "50%",
}}
>
<Avatar src={team.settings.avatar} /> <Avatar src={team.settings.avatar} />
</IconButton> </IconButton>
<IconButton> </Tooltip>
{/* <AnotherIcon /> */} <Tooltip title={"Chat Assistant"} placement="right">
<IconButton
onClick={openCrisp}
>
<RobotIcon />
</IconButton> </IconButton>
</Tooltip>
<Box width={"100%"} borderBottom={"1px solid grey"} />
{teams.map((t, i)=> {
if (t.settings?.key === team.key()) return null;
return (
<Tooltip title={t.settings?.name} key={"chat-team-"+i} placement="right">
<IconButton
onClick={() => setSelectedTeam(Team.create(t))}
sx={{
border: selectedTeam.key() === t.settings?.key ? "1px solid white" : "1px solid transparent",
borderRadius: "50%",
}}
>
<Avatar src={t.settings?.avatar} />
</IconButton>
</Tooltip>
)
})}
</Box> </Box>
{/* Main chat area */} {/* Main chat area */}
@ -88,13 +134,13 @@ export function ChatDrawer(props: Props) {
<IconButton onClick={onClose}> <IconButton onClick={onClose}>
<ChevronRight /> <ChevronRight />
</IconButton> </IconButton>
<Typography>{team.name()} Chat</Typography> <Typography>{selectedTeam.name()} Chat</Typography>
</Box> </Box>
<Box className={classes.chatContainer}> <Box className={classes.chatContainer}>
<Chat objectKey={team.settings.key} type={pond.NoteType.NOTE_TYPE_TEAM} /> <Chat objectKey={selectedTeam.key()} type={pond.NoteType.NOTE_TYPE_TEAM} />
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Drawer> </Drawer>
) );
} }

View file

@ -74,10 +74,23 @@ export default function ChatMessage(props: Props) {
const mappedPerm = pond.Permission[perm as keyof typeof pond.Permission]; const mappedPerm = pond.Permission[perm as keyof typeof pond.Permission];
return mappedPerm; // Return null for invalid keys return mappedPerm; // Return null for invalid keys
}).filter(Boolean); // Optionally filter out any null values }).filter(Boolean); // Optionally filter out any null values
if (user.id() === props.chat.profile?.id) {
perms = [
pond.Permission.PERMISSION_READ,
pond.Permission.PERMISSION_WRITE
]
}
} else { } else {
// console.error("data.permissions is not an array or does not exist"); // console.error("data.permissions is not an array or does not exist");
if (user.id() === props.chat.profile?.id) {
perms = [
pond.Permission.PERMISSION_READ,
pond.Permission.PERMISSION_WRITE
]
} else {
perms = []; perms = [];
} }
}
return perms return perms
} }
const permissions = generatePermissions() const permissions = generatePermissions()

View file

@ -72,6 +72,7 @@ export default function ChatOutput(props: Props) {
<Box ref={containerRef} onScroll={onScroll} style={{ <Box ref={containerRef} onScroll={onScroll} style={{
overflowY: "scroll", overflowY: "scroll",
overflowX: "hidden", overflowX: "hidden",
height: "100%",
}}> }}>
{props.messages.length < props.totalMessages && ( {props.messages.length < props.totalMessages && (
<ListItemButton onClick={seeMore} sx={{ justifyContent: "center"}}> <ListItemButton onClick={seeMore} sx={{ justifyContent: "center"}}>

111
src/chat/CrispChat.ts Normal file
View file

@ -0,0 +1,111 @@
import { Crisp } from "crisp-sdk-web";
const CRISP_HIDDEN_STYLE_ID = "crisp-hide-override";
const ANIMATION_DURATION_MS = 300;
let initialized = false;
/**
* Initialize Crisp and immediately hide the default chat button.
* Call this once on app load (e.g., in UserWrapper after user data is ready).
*/
export function initCrisp(opts: {
websiteId: string;
email?: string;
nickname?: string;
phone?: string;
tokenId?: string;
}) {
if (initialized) return;
Crisp.configure(opts.websiteId);
Crisp.session.reset();
if (opts.email) Crisp.user.setEmail(opts.email);
if (opts.nickname) Crisp.user.setNickname(opts.nickname);
if (opts.phone) Crisp.user.setPhone(opts.phone);
if (opts.tokenId) Crisp.setTokenId(opts.tokenId);
initialized = true;
// Hide the chat button off-screen
injectCrispStyles();
// Auto-hide when the user closes the chat
Crisp.chat.onChatClosed(() => {
hideCrispChatButton();
});
}
/**
* Inject base styles that keep Crisp hidden until we want it.
* Uses opacity + visibility so Crisp keeps its default bottom-right positioning.
*/
function injectCrispStyles() {
let style = document.getElementById(CRISP_HIDDEN_STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = CRISP_HIDDEN_STYLE_ID;
document.head.appendChild(style);
}
style.textContent = `
#crisp-chatbox {
z-index: 99999 !important;
transition: opacity ${ANIMATION_DURATION_MS}ms ease-in-out,
visibility ${ANIMATION_DURATION_MS}ms ease-in-out !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
#crisp-chatbox.crisp-visible {
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
}
`;
}
/**
* Show the Crisp widget and open the chat window.
* Safe to call from any onClick handler.
*/
export function openCrispChat() {
const chatbox = document.getElementById("crisp-chatbox");
if (chatbox) {
chatbox.classList.add("crisp-visible");
setTimeout(() => {
Crisp.chat.open();
}, ANIMATION_DURATION_MS);
} else {
Crisp.chat.open();
}
}
/**
* Close the chat window and hide the widget.
*/
export function closeCrispChat() {
Crisp.chat.close();
hideCrispChatButton();
}
/**
* Show the default Crisp button without opening the chat.
*/
export function showCrispButton() {
const chatbox = document.getElementById("crisp-chatbox");
if (chatbox) {
chatbox.classList.add("crisp-visible");
}
}
/**
* Hide the default Crisp button.
*/
export function hideCrispChatButton() {
const chatbox = document.getElementById("crisp-chatbox");
if (chatbox) {
chatbox.classList.remove("crisp-visible");
}
}

View file

@ -323,21 +323,21 @@ export default function Devices() {
return false return false
} }
useEffect(() => { // useEffect(() => {
console.log("has no2: "+hasNo2) // console.log("has no2: "+hasNo2)
}, [hasNo2]) // }, [hasNo2])
useEffect(() => { // useEffect(() => {
console.log("has co2: "+hasCo2) // console.log("has co2: "+hasCo2)
}, [hasCo2]) // }, [hasCo2])
useEffect(() => { // useEffect(() => {
console.log("has co: "+hasNo2) // console.log("has co: "+hasNo2)
}, [hasNo2]) // }, [hasNo2])
useEffect(() => { // useEffect(() => {
console.log("has o2: "+hasO2) // console.log("has o2: "+hasO2)
}, [hasO2]) // }, [hasO2])
const openProvisionDialog = () => { const openProvisionDialog = () => {
setIsProvisionDialogOpen(true); setIsProvisionDialogOpen(true);

View file

@ -223,7 +223,7 @@ export default function TeamPage() {
const isStreamline = IsStreamline() const isStreamline = IsStreamline()
return ( return (
<PageContainer spacing={isMobile ? 0 : 2}> <PageContainer spacing={isMobile ? 1 : 2}>
<Box className={classes.container}> <Box className={classes.container}>
<Box className={classes.leftBox}> <Box className={classes.leftBox}>
{title()} {title()}

View file

@ -2,7 +2,6 @@ import MarketWhite from "assets/marketplaceImages/marketplaceIconLight.png";
import MarketBlack from "assets/marketplaceImages/marketplaceIconDark.png"; import MarketBlack from "assets/marketplaceImages/marketplaceIconDark.png";
import { ImgIcon } from "common/ImgIcon"; import { ImgIcon } from "common/ImgIcon";
import { useThemeType } from "hooks"; import { useThemeType } from "hooks";
import React from "react";
interface Props { interface Props {
type?: "light" | "dark"; type?: "light" | "dark";

View file

@ -0,0 +1,23 @@
import RobotWhite from "assets/common/robotIconLight.png";
import RobotBlack from "assets/common/robotIconDark.png";
import { ImgIcon } from "common/ImgIcon";
import { useThemeType } from "hooks";
interface Props {
type?: "light" | "dark";
}
export default function RobotIcon(props: Props) {
const themeType = useThemeType();
const { type } = props;
const src = () => {
if (type) {
return type === "light" ? RobotWhite : RobotBlack;
}
return themeType === "light" ? RobotBlack : RobotWhite;
};
return <ImgIcon alt="marketplace" src={src()} />;
}

View file

@ -1,7 +1,7 @@
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useHTTP } from "hooks"; import { useHTTP } from "hooks";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers"; // import { useGlobalState } from "providers";
import { createContext, PropsWithChildren, useContext } from "react"; import { createContext, PropsWithChildren, useContext } from "react";
import { pondURL } from "./pond"; import { pondURL } from "./pond";
@ -37,7 +37,7 @@ interface Props {}
export default function NoteProvider(props: PropsWithChildren<Props>) { export default function NoteProvider(props: PropsWithChildren<Props>) {
const { children } = props; const { children } = props;
const { get, del, post, put } = useHTTP(); const { get, del, post, put } = useHTTP();
const [{as}] = useGlobalState() // const [{as}] = useGlobalState()
const addNote = (note: pond.NoteSettings, attachments?: string[]) => { const addNote = (note: pond.NoteSettings, attachments?: string[]) => {
let url = pondURL("/notes" + (attachments ? "?attachments=" + attachments.toString() : "")) let url = pondURL("/notes" + (attachments ? "?attachments=" + attachments.toString() : ""))
@ -88,9 +88,9 @@ export default function NoteProvider(props: PropsWithChildren<Props>) {
orderBy?: string, orderBy?: string,
search?: string, search?: string,
asRoot?: boolean, asRoot?: boolean,
otherTeam?: string // otherTeam?: string
) => { ) => {
const view = otherTeam ? otherTeam : as // const view = otherTeam ? otherTeam : as
let url = pondURL( let url = pondURL(
"/notes" + "/notes" +
"?limit=" + "?limit=" +
@ -100,9 +100,10 @@ export default function NoteProvider(props: PropsWithChildren<Props>) {
("&order=" + (order ? order : "asc")) + ("&order=" + (order ? order : "asc")) +
("&by=" + (orderBy ? orderBy : "key")) + ("&by=" + (orderBy ? orderBy : "key")) +
(search ? "&search=" + search : "") + (search ? "&search=" + search : "") +
(asRoot ? "&asRoot=" + asRoot.toString() : "") + (asRoot ? "&asRoot=" + asRoot.toString() : ""),
(view ? "&as=" + view : "") // (view ? "&as=" + "" : "")
) )
// console.log(url)
return new Promise<AxiosResponse<pond.ListNotesResponse>>((resolve, reject) => { return new Promise<AxiosResponse<pond.ListNotesResponse>>((resolve, reject) => {
get<pond.ListNotesResponse>(url).then(resp => { get<pond.ListNotesResponse>(url).then(resp => {
resp.data = pond.ListNotesResponse.fromObject(resp.data) resp.data = pond.ListNotesResponse.fromObject(resp.data)
@ -119,10 +120,10 @@ export default function NoteProvider(props: PropsWithChildren<Props>) {
order?: "asc" | "desc", order?: "asc" | "desc",
orderBy?: string, orderBy?: string,
search?: string, search?: string,
asRoot?: boolean, // asRoot?: boolean,
otherTeam?: string // otherTeam?: string
) => { ) => {
const view = otherTeam ? otherTeam : as // const view = otherTeam ? otherTeam : as
let url = pondURL( let url = pondURL(
"/chats" + "/chats" +
"?limit=" + "?limit=" +
@ -131,10 +132,11 @@ export default function NoteProvider(props: PropsWithChildren<Props>) {
offset + offset +
("&order=" + (order ? order : "asc")) + ("&order=" + (order ? order : "asc")) +
("&by=" + (orderBy ? orderBy : "key")) + ("&by=" + (orderBy ? orderBy : "key")) +
(search ? "&search=" + search : "") + (search ? "&search=" + search : ""),
(asRoot ? "&asRoot=" + asRoot.toString() : "") + // (asRoot ? "&asRoot=" + asRoot.toString() : ""),
(view ? "&as=" + view : "") // (view ? "&as=" + view : "")
) )
// console.log(asRoot)
return new Promise<AxiosResponse<pond.ListChatsResponse>>((resolve, reject) => { return new Promise<AxiosResponse<pond.ListChatsResponse>>((resolve, reject) => {
get<pond.ListChatsResponse>(url).then(resp => { get<pond.ListChatsResponse>(url).then(resp => {
resp.data = pond.ListChatsResponse.fromObject(resp.data) resp.data = pond.ListChatsResponse.fromObject(resp.data)

View file

@ -117,9 +117,11 @@ export default function TeamProvider(props: PropsWithChildren<Props>) {
asRoot?: boolean, asRoot?: boolean,
otherTeam?: string, otherTeam?: string,
prefixSearch?: string, prefixSearch?: string,
useImitation?: boolean,
) => { ) => {
//let asText = team ? "&as=" + team.key() : ""; //let asText = team ? "&as=" + team.key() : "";
const view = otherTeam ? otherTeam : (as.length > 0 ? as : undefined) let view = otherTeam ? otherTeam : (as.length > 0 ? as : undefined)
if (useImitation === undefined || useImitation === false) view = ""
let url = pondURL( let url = pondURL(
"/teams" + "/teams" +
"?limit=" + "?limit=" +