Skip to content

Add <HtmlPanel> component #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## v0.1.4

- Add `<HtmlPanel>` component

## v0.1.3

- Add `signature?: string` prop to `Session`
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,28 @@ Accepted props:
loading
- `chatboxRef` (resp. `inboxRef`, `popupRef`) - Pass a ref (created with `useRef`) and it'll be set to the vanilla JS [Chatbox](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Chatbox/) (resp. [Inbox](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Inbox/), [Popup](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Popup/)) instance. See [above](#using-refs) for an example.
- All [Talk.ChatboxOptions](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Session/#ChatboxOptions)
- `children?: ReactNode` - Optional. You can provide an `<HtmlPanel>` component as a child to use [HTML Panels](https://talkjs.com/docs/Features/Customizations/HTML_Panels/).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should explicitly say that HtmlPanels can be the only direct children


Accepted events (props that start with "on"):

- All events accepted by [`Talk.Chatbox`](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Chatbox/#Chatbox__methods) (resp. [Inbox](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Inbox/#Inbox__methods), [Popup](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Popup/#Popup__methods))

Note: For `<Chatbox>` and `<Popup>`, you must provide exactly one of `conversationId` and `syncConversation`. For `<Inbox>`, leaving both unset selects the latest conversation this user participates in (if any). See [Inbox.select](https://talkjs.com/docs/Reference/JavaScript_Chat_SDK/Inbox/#Inbox__select) for more information.

### `<HtmlPanel>`

Accepted props:

- `url: string` - The URL you want to load inside the HTML panel. The URL can be absolute or relative. We recommend using same origin pages to have better control of the page. Learn more about HTML Panels and same origin pages [here](https://talkjs.com/docs/Features/Customizations/HTML_Panels/)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be more precise, “to have better control” is a bit meaningless and also kinda wrong. It must be the same origin if they want to render children into it. If they don’t want to render their contents right here from React then the origin doesn’t matter. (Btw make sure you test both cases, ie that if there’s no children passed, and the panel has a different origin, there are no errors).


- `height?: number` - Optional. The panel height in pixels. Defaults to `100px`.

- `show?: boolean` - Optional. Sets the visibility of the panel. Defaults to `true`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth explaining how this is different vs conditionally rendering the HtmlPanel element


- `conversationId?: string` - Optional. If given, the panel will only show up for the conversation that has an `id` matching the one given.

- `children: React.ReactNode` - The content that gets rendered inside the `<body>` of the panel.


## Contributing

Expand Down
22 changes: 20 additions & 2 deletions example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./App.css";

import { Session, Chatbox } from "../lib/main";
import { Session, Chatbox, HtmlPanel } from "../lib/main";
import Talk from "talkjs";
import { ChangeEvent, useCallback, useMemo, useRef, useState } from "react";

Expand Down Expand Up @@ -104,6 +104,9 @@ function App() {
setDn(JSON.parse(event.target!.value));
}, []);

const [panelHeight, setPanelHeight] = useState(100);
const [panelVisible, setPanelVisible] = useState(true);

if (typeof import.meta.env.VITE_APP_ID !== "string") {
return (
<div style={{ maxWidth: "50em" }}>
Expand Down Expand Up @@ -150,8 +153,23 @@ function App() {
loadingComponent={<span>LOADING....</span>}
{...(blur ? { onBlur } : {})}
style={{ width: 500, height: 600 }}
/>
>
<HtmlPanel
url="/example/panel.html"
height={panelHeight}
show={panelVisible}
>
I am an HTML panel.
<button
onClick={() => setPanelHeight(panelHeight > 100 ? 100 : 150)}
>
Toggle panel height
</button>
<button onClick={() => setPanelVisible(false)}>Hide panel</button>
</HtmlPanel>
</Chatbox>
</Session>

<button onClick={otherMe}>switch user (new session)</button>
<br />
<button onClick={switchConv}>
Expand Down
19 changes: 19 additions & 0 deletions example/panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit meaningless, no? Maybe just keep the title out

<style>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we prepend something here like

<link rel="stylesheet" href="./your-styles.css">
<!-- Put your app's CSS here. For instance, if your bundler generates a CSS file from all component styles, load it here as well and your components will be styled correctly inside the HTML Panel -->

body {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe let's just give body, html a padding: 0 for good measure? (and eg a margin on the button). i expect anybody will want to do that, so the html panel behaves pretty much like a div.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean here; the buttons already have margin: 0.6rem auto

background-color: lightblue;
}
button {
display: block;
width: 10rem;
margin: 0.6rem auto;
}
</style>
</head>
<body></body>
</html>
94 changes: 94 additions & 0 deletions lib/HtmlPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useContext, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Talk from "talkjs";
import { BoxContext } from "./MountedBox";

type HtmlPanelProps = {
/**
* The URL you want to load inside the HTML panel. The URL can be absolute or
* relative. We recommend using same origin pages to have better control of
* the page. Learn more about HTML Panels and same origin pages {@link https://talkjs.com/docs/Features/Customizations/HTML_Panels/ | here}.
*/
url: string;

/** The panel height in pixels. Defaults to `100px`. */
height?: number;

/** Sets the visibility of the panel. Defaults to `true`. */
show?: boolean;

/** If given, the panel will only show up for the conversation that has an `id` matching the one given. */
conversationId?: string;

/** The content that gets rendered inside the `<body>` of the panel. */
children: React.ReactNode;
};

type State =
| { type: "none" }
| { type: "loading" }
| { type: "loaded"; panel: Talk.HtmlPanel };

export function HtmlPanel({
url,
height = 100,
show = true,
conversationId,
children,
}: HtmlPanelProps) {
const [state, setState] = useState<State>({ type: "none" });
const box = useContext(BoxContext);

useEffect(() => {
async function run() {
if (state.type !== "none" || !box) return;

setState({ type: "loading" });
const panel = await box.createHtmlPanel({
url,
conversation: conversationId,
height,
show,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meta comment, I just realized that a set of <HtmlPanel> components in a react app is semantically equivalent to the htmlPanelOptions we pass from the JSSDK in the frontend.

this is actually super nuts. we internally have a declarative data structure, we built an imperative interface around that to mutate that data structure (createHtmlPanel, destroy, show, etc), and now here we're building the same thing in the opposite direction!

i don't think we need to do anything rn with this observation but i'm thinking that maybe one day we should deprecate all create* and set* methods entirely in favour of some vanilla JS, react-y declarative "spec your chatbox here" interface. would make the entire react sdk a near no-op, also.

await panel.windowLoadedPromise;
setState({ type: "loaded", panel });
}

run();

return () => {
if (state.type === "loaded") {
state.panel.destroy();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we always destroy it, regardless of loading state? else if customer code creates it and then quickly removes it, the chatbox will create it anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(maybe just put the Promise<HtmlPanel> in the state, and do state.panelPromise.then(panel => panel.destroy()). i don't think there's a downside to already-resolved panels being destroyed in the next microtask instead of right there in the cleanup callback, right?

setState({ type: "none" });
}
};
// We intentionally exclude `height` and `show` from the dependency array so
// that we update them later via methods instead of by re-creating the
// entire panel from scratch each time.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, url, box, conversationId]);

useEffect(() => {
if (state.type === "loaded") {
state.panel.setHeight(height);
}
}, [state, height]);

useEffect(() => {
if (state.type === "loaded") {
if (show) {
state.panel.show();
} else {
state.panel.hide();
}
}
}, [state, show]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be state.type instead of the entire state? (same for height)


return (
<>
{state.type === "loaded" &&
createPortal(children, state.panel.window.document.body)}
</>
Copy link
Contributor

@eteeselink eteeselink Nov 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

magic, isn't it? :D

btw i think this needs some "if no children given, then no portal" type logic, so we dont get errors when people mount non-same-origin html panels (eg because they're SSR'ing something from somewhere else)

);
}
14 changes: 10 additions & 4 deletions lib/MountedBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CSSProperties, ReactNode, useRef } from "react";
import React, { CSSProperties, ReactNode, useRef } from "react";
import type Talk from "talkjs";
import { EventListeners } from "./EventListeners";
import { useMountBox } from "./hooks";
Expand All @@ -11,31 +11,37 @@ interface Props {
className?: string;

handlers: Record<`on${string}`, Func>;
children?: React.ReactNode;
}

/**
* Mounts the given `UIBox` and attaches event handlers to it. Renders a
* `loadingComponent` fallback until the mount is complete.
*/
export function MountedBox(props: Props & { session: Talk.Session }) {
const { box, loadingComponent, className, handlers } = props;
const { box, loadingComponent, className, children, handlers } = props;

const ref = useRef<HTMLDivElement>(null);
const mounted = useMountBox(box, ref.current);

const style = mounted ? props.style : { ...props.style, display: "none" };

return (
<>
<BoxContext.Provider value={box}>
{!mounted && (
<div style={props.style} className={className}>
{loadingComponent}
</div>
)}

<div ref={ref} style={style} className={className} />
{children}

<EventListeners target={box} handlers={handlers} />
</>
</BoxContext.Provider>
);
}

export const BoxContext = React.createContext<Talk.UIBox | undefined>(
undefined,
);
1 change: 1 addition & 0 deletions lib/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { Chatbox } from "./ui/Chatbox";
export { Inbox } from "./ui/Inbox";
export { Popup } from "./ui/Popup";
export { useSession } from "./SessionContext";
export { HtmlPanel } from "./HtmlPanel";
3 changes: 3 additions & 0 deletions lib/ui/Chatbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type ChatboxProps = UIBoxProps<Talk.Chatbox> &
loadingComponent?: ReactNode;
style?: CSSProperties;
className?: string;
children?: React.ReactNode;
};

export function Chatbox(props: ChatboxProps) {
Expand All @@ -39,6 +40,7 @@ function ActiveChatbox(props: ChatboxProps & { session: Talk.Session }) {
style,
className,
loadingComponent,
children,
...optionsAndEvents
} = props;

Expand All @@ -60,6 +62,7 @@ function ActiveChatbox(props: ChatboxProps & { session: Talk.Session }) {
style={style}
loadingComponent={loadingComponent}
handlers={events}
children={children}
/>
);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"url": "https://github.com/talkjs/talkjs-react/issues"
},
"homepage": "https://talkjs.com",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"files": [
"dist"
Expand Down