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 all 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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

## v0.1.9

- Added `"use client"` banner to build outputs
Expand All @@ -11,10 +12,22 @@

- Added `show` prop to `Popup`, that allows you to specify whether the popup should be shown or hidden.

## v0.1.7-beta.0

- Add `<HtmlPanel>` component

**Note:** This component is currently experimental.

## v0.1.6

- Added `asGuest?: boolean` prop to `Chatbox`, `Inbox` and `Popup`.

## v0.1.6-beta.0

- Add `<HtmlPanel>` component

**Note:** This component is currently experimental.

## v0.1.5

- Output ES2015.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function ChatComponent() {
export default ChatComponent;
```

For more details and explanation, see our [getting started guide](/Getting_Started/Frameworks/React/).
For more details and explanation, see our [getting started guide](https://talkjs.com/docs/Getting_Started/Frameworks/React/).

## Contributing

Expand Down
81 changes: 62 additions & 19 deletions example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import "./App.css";

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

const convIds = ["talk-react-94872948u429843", "talk-react-194872948u429843"];
const users = [
Expand Down Expand Up @@ -53,11 +60,6 @@ function App() {
setConvId(convIds[nextConv]);
}, [convId]);

const createUser = useCallback(() => {
console.log("createUser");
return new Talk.User(me);
}, [me]);

const createConv = useCallback(
(session: Talk.Session) => {
console.log("createConv");
Expand Down Expand Up @@ -104,6 +106,11 @@ function App() {
setDn(JSON.parse(event.target!.value));
}, []);

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

const [renderPanel, setPanel] = useState(false);

if (typeof import.meta.env.VITE_APP_ID !== "string") {
return (
<div style={{ maxWidth: "50em" }}>
Expand All @@ -129,7 +136,7 @@ function App() {
<>
<Session
appId={import.meta.env.VITE_APP_ID}
syncUser={createUser}
syncUser={() => new Talk.User(me)}
onBrowserPermissionNeeded={onPerm}
onUnreadsChange={onUnreads}
sessionRef={sessionRef}
Expand All @@ -150,9 +157,34 @@ function App() {
loadingComponent={<span>LOADING....</span>}
{...(blur ? { onBlur } : {})}
style={{ width: 500, height: 600 }}
/>
>
{renderPanel && (
<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>
<UnreadsDisplay />
</Session>

<button
onClick={() => {
setPanel((x) => !x);
setPanelVisible(true);
}}
>
{renderPanel ? "Unmount" : "Mount"} HTML panel
</button>
<button onClick={otherMe}>switch user (new session)</button>
<br />
<button onClick={switchConv}>
Expand Down Expand Up @@ -203,19 +235,30 @@ function UnreadsDisplay() {
if (unreads === undefined) {
content = <p>unreads is undefined (no session)</p>;
} else if (unreads.length === 0) {
content = <p>No unread messages</p>
content = <p>No unread messages</p>;
} else {
content = <ul>
{unreads.map(u => {
return <li key={u.conversation.id}>{u.conversation.id} - {u.lastMessage.sender?.name || "system"}: {u.lastMessage.body}</li>
})}
</ul>
content = (
<ul>
{unreads.map((u) => {
return (
<li key={u.conversation.id}>
{u.conversation.id} - {u.lastMessage.sender?.name || "system"}:{" "}
{u.lastMessage.body}
</li>
);
})}
</ul>
);
}

return <details>
<summary><strong>Unreads rendered with useUnreads</strong></summary>
{content}
</details>
return (
<details>
<summary>
<strong>Unreads rendered with useUnreads</strong>
</summary>
{content}
</details>
);
}

export default App;
31 changes: 31 additions & 0 deletions example/panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<!--
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
-->
<link rel="stylesheet" href="./your-styles.css" />

<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 -->

html,
body {
margin: 0;
padding: 0;
}
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>
103 changes: 103 additions & 0 deletions lib/HtmlPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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;
};

export function HtmlPanel({
url,
height = 100,
show = true,
conversationId,
children,
}: HtmlPanelProps) {
const [panel, setPanel] = useState<undefined | Talk.HtmlPanel>(undefined);
const box = useContext(BoxContext);

const normalizedPanelUrl = new URL(url, document.baseURI);
const baseUrl = new URL(document.baseURI);
const isCrossOrigin = normalizedPanelUrl.origin !== baseUrl.origin;

useEffect(() => {
async function run() {
if (!box || panel) return Promise.resolve(panel);

const newPanel = await box.createHtmlPanel({
url,
conversation: conversationId,
height,
// If the frame is cross-origin, we can't render children into it anyway
// so we show the panel straight away. If we can render, we hide it
// first and wait for the DOMContentLoaded event to fire before showing
// the panel to avoid a flash of content that's missing the React
// portal.
show: isCrossOrigin,
});

if (!isCrossOrigin) {
// This promise will never resolve if the panel isn't on the same origin.
// We skip the `await` if that's the case.
await newPanel.DOMContentLoadedPromise;
if (show) {
newPanel.show();
}
}

setPanel(newPanel);
return newPanel;
}

const panelPromise = run();

return () => {
panelPromise.then((panel) => {
panel?.destroy().then(() => {
setPanel(undefined);
});
});
};
// 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
}, [url, box, conversationId]);

useEffect(() => {
panel?.setHeight(height);
}, [panel, height]);

useEffect(() => {
if (show) {
panel?.show();
} else {
panel?.hide();
}
}, [panel, show]);

const shouldRender = !isCrossOrigin && panel && children;

return (
<>{shouldRender && createPortal(children, panel.window.document.body)}</>
);
}
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,
);
37 changes: 29 additions & 8 deletions lib/Session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,21 @@ export function Session(props: SessionProps) {
Talk.ready.then(() => markReady(true));
}, []);

useEffect(() => {
if (ready) {
const me =
typeof syncUser === "function"
? syncUser()
: syncUser ?? new Talk.User(userId);
const me = ready
? typeof syncUser === "function"
? syncUser()
: syncUser ?? new Talk.User(userId)
: null;

const session = new Talk.Session({ appId, me, token, tokenFetcher, signature });
useEffect(() => {
if (me) {
const session = new Talk.Session({
appId,
me,
token,
tokenFetcher,
signature,
});
setSession(session);
if (sessionRef) {
sessionRef.current = session;
Expand All @@ -65,7 +72,21 @@ export function Session(props: SessionProps) {
}
};
}
}, [ready, signature, appId, userId, syncUser, sessionRef]);
// We intentionally add `me?.id` to the dependency array here instead of
// just `me`, because `me` is an object so a shallow comparison will always
// return `false`.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ready,
signature,
appId,
userId,
me?.id,
token,
tokenFetcher,
sessionRef,
]);

useMethod(
session,
Expand Down
Loading