From 1987c257bafecd99183d5d389bbf19d38318bfc5 Mon Sep 17 00:00:00 2001 From: Tristan Yang Date: Thu, 27 Oct 2022 21:21:54 +0800 Subject: [PATCH] feat: widget sdk --- config/paths.js | 4 +- config/webpack.config.js | 8 +- package.json | 1 + public/embed_demo.html | 30 ------- public/{embed.html => widget.html} | 21 ++--- public/widget.js | 40 +++++++++ public/widget_demo.html | 13 +++ src/app/slices/message.user.ts | 2 +- src/common/hook/useSendMessage.ts | 14 ++-- src/embed/Chat.tsx | 9 -- src/embed/index.tsx | 18 ---- src/{embed.tsx => index-widget.tsx} | 13 +-- src/routes/index.tsx | 4 +- src/widget/Icon.tsx | 16 ++++ src/widget/Popup/Header.tsx | 25 ++++++ src/widget/Popup/Line.tsx | 14 ++++ src/widget/Popup/MessageFeed/Message/Text.tsx | 81 ++++++++++++++++++ src/widget/Popup/MessageFeed/index.tsx | 32 +++++++ src/widget/Popup/Welcome.tsx | 30 +++++++ src/widget/Popup/close.svg | 3 + src/widget/Popup/index.tsx | 83 +++++++++++++++++++ src/widget/index.tsx | 29 +++++++ src/widget/usePreload.ts | 42 ++++++++++ tailwind.config.js | 18 +++- yarn.lock | 2 +- 25 files changed, 456 insertions(+), 96 deletions(-) delete mode 100644 public/embed_demo.html rename public/{embed.html => widget.html} (50%) create mode 100644 public/widget.js create mode 100644 public/widget_demo.html delete mode 100644 src/embed/Chat.tsx delete mode 100644 src/embed/index.tsx rename src/{embed.tsx => index-widget.tsx} (50%) create mode 100644 src/widget/Icon.tsx create mode 100644 src/widget/Popup/Header.tsx create mode 100644 src/widget/Popup/Line.tsx create mode 100644 src/widget/Popup/MessageFeed/Message/Text.tsx create mode 100644 src/widget/Popup/MessageFeed/index.tsx create mode 100644 src/widget/Popup/Welcome.tsx create mode 100644 src/widget/Popup/close.svg create mode 100644 src/widget/Popup/index.tsx create mode 100644 src/widget/index.tsx create mode 100644 src/widget/usePreload.ts diff --git a/config/paths.js b/config/paths.js index bf0ae7c6..0e377741 100644 --- a/config/paths.js +++ b/config/paths.js @@ -58,8 +58,8 @@ module.exports = { appPublic: resolveApp("public"), appHtml: resolveApp("public/index.html"), appIndexJs: resolveModule(resolveApp, "src/index"), - embedAppHtml: resolveApp("public/embed.html"), - embedAppIndexJs: resolveModule(resolveApp, "src/embed"), + widgetAppHtml: resolveApp("public/widget.html"), + widgetAppIndexJs: resolveModule(resolveApp, "src/index-widget"), appPackageJson: resolveApp("package.json"), appSrc: resolveApp("src"), appTsConfig: resolveApp("tsconfig.json"), diff --git a/config/webpack.config.js b/config/webpack.config.js index 6fbe2d43..da871227 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -133,7 +133,7 @@ module.exports = function (webpackEnv) { : isEnvDevelopment && "cheap-module-source-map", // These are the "entry points" to our application. // This means they will be the "root" imports that are included in JS bundle. - entry: { main: paths.appIndexJs, embed: paths.embedAppIndexJs }, + entry: { main: paths.appIndexJs, widget: paths.widgetAppIndexJs }, output: { // The build folder. path: paths.appBuild, @@ -439,10 +439,10 @@ module.exports = function (webpackEnv) { Object.assign( {}, { - filename: "embed.html", + filename: "widget.html", inject: true, - template: paths.embedAppHtml, - chunks: ["embed"] + template: paths.widgetAppHtml, + chunks: ["widget"] }, isEnvProduction ? { diff --git a/package.json b/package.json index c355860e..e3b6ea6a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@uiball/loaders": "^1.2.6", "bfj": "^7.0.2", "browserslist": "^4.21.4", + "clsx": "^1.2.1", "copy-image-clipboard": "^2.1.2", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^4.2.2", diff --git a/public/embed_demo.html b/public/embed_demo.html deleted file mode 100644 index 16e44a66..00000000 --- a/public/embed_demo.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - Vocechat Embed Demo - - - - -
- -
- - diff --git a/public/embed.html b/public/widget.html similarity index 50% rename from public/embed.html rename to public/widget.html index 43246af5..62c21086 100644 --- a/public/embed.html +++ b/public/widget.html @@ -5,23 +5,20 @@ - VoceChat Embed + VoceChat Widget - +
- diff --git a/public/widget.js b/public/widget.js new file mode 100644 index 00000000..69e6d750 --- /dev/null +++ b/public/widget.js @@ -0,0 +1,40 @@ +const { + domain = location.origin, + hostId = 1, + closeWidth = 52, + closeHeight = 52, + openWidth = 600, + openHeight = 800 +} = document.currentScript.dataset; +const wrapper = document.createElement("iframe"); +const styles = { + position: "fixed", + right: "15px", + bottom: "15px", + border: "none" +}; +Object.assign(wrapper.style, styles); +wrapper.src = `${domain}/widget.html?host=${hostId}`; +wrapper.width = closeWidth; +wrapper.height = closeHeight; +wrapper.frameborder = 0; +window.addEventListener( + "message", + (event) => { + const { data: CMD } = event; + switch (CMD) { + case "OPEN": + wrapper.setAttribute("width", openWidth); + wrapper.setAttribute("height", openHeight); + break; + case "CLOSE": + wrapper.setAttribute("width", closeWidth); + wrapper.setAttribute("height", closeHeight); + break; + default: + break; + } + }, + false +); +document.body.appendChild(wrapper); diff --git a/public/widget_demo.html b/public/widget_demo.html new file mode 100644 index 00000000..47f13a84 --- /dev/null +++ b/public/widget_demo.html @@ -0,0 +1,13 @@ + + + + + + + Vocechat Widget Demo + + + + + + diff --git a/src/app/slices/message.user.ts b/src/app/slices/message.user.ts index 1bacafa6..efbb0035 100644 --- a/src/app/slices/message.user.ts +++ b/src/app/slices/message.user.ts @@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; export interface State { ids: number[]; // todo: check object type - byId: { [id: number]: any }; + byId: { [id: number]: number[] }; } const initialState: State = { diff --git a/src/common/hook/useSendMessage.ts b/src/common/hook/useSendMessage.ts index e5d17a9d..283bc12d 100644 --- a/src/common/hook/useSendMessage.ts +++ b/src/common/hook/useSendMessage.ts @@ -1,9 +1,10 @@ import toast from "react-hot-toast"; -import { removeReplyingMessage, addReplyingMessage } from "../../app/slices/message"; +import { removeReplyingMessage, addReplyingMessage, MessagePayload } from "../../app/slices/message"; import { useSendChannelMsgMutation } from "../../app/services/channel"; import { useSendMsgMutation } from "../../app/services/user"; import { useReplyMessageMutation } from "../../app/services/message"; import { useAppDispatch, useAppSelector } from "../../app/store"; +import { ContentTypeKey } from "../../types/message"; interface Props { context: "user" | "channel"; @@ -18,12 +19,7 @@ interface SendMessagesDTO { channels: number[]; } -interface SendMessageDTO { - context: "user" | "channel"; - from: number; - to: number; -} - +type SendMessageDTO = { type: ContentTypeKey } & Partial const useSendMessage = (props?: Props) => { const { context = "user", from = 0, to = 0 } = props || {}; const dispatch = useAppDispatch(); @@ -65,7 +61,7 @@ const useSendMessage = (props?: Props) => { const sendMessage = async ({ type = "text", content, - properties = {}, + properties, reply_mid, ...rest }: SendMessageDTO) => { @@ -80,7 +76,7 @@ const useSendMessage = (props?: Props) => { await sendFn({ id: to, content, - properties: { ...properties }, + properties, type, from_uid: from, ...rest diff --git a/src/embed/Chat.tsx b/src/embed/Chat.tsx deleted file mode 100644 index fe1283e2..00000000 --- a/src/embed/Chat.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// import React from "react"; - -type Props = {}; - -const Chat = (props: Props) => { - return
Chat
; -}; - -export default Chat; diff --git a/src/embed/index.tsx b/src/embed/index.tsx deleted file mode 100644 index 72386d67..00000000 --- a/src/embed/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useState } from "react"; -import Chat from "./Chat"; -type Props = {}; - -function Embed({ }: Props) { - const [visible, setVisible] = useState(false); - const toggleVisible = () => { - setVisible((prev) => !prev); - }; - return ( -
- - {visible && } -
- ); -} - -export default Embed; diff --git a/src/embed.tsx b/src/index-widget.tsx similarity index 50% rename from src/embed.tsx rename to src/index-widget.tsx index 49afe504..ebd0b778 100644 --- a/src/embed.tsx +++ b/src/index-widget.tsx @@ -1,13 +1,14 @@ import ReactDOM from "react-dom/client"; -import Embed from "./embed/index"; +import { Provider } from "react-redux"; + +import Widget from "./widget/index"; import './assets/index.css'; -import MarkdownStyleOverride from "./common/component/MarkdownStyleOverride"; +import store from "./app/store"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); root.render( - <> - - - + + + ); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 587b36e1..8f493f0b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -28,7 +28,7 @@ import HomePage from "./home"; import ChatPage from "./chat"; import Loading from "../common/component/Loading"; import store, { useAppSelector } from "../app/store"; -import GuestLogining from "./guest"; +import GuestLogin from "./guest"; let toastId: string; const PageRoutes = () => { const { @@ -51,7 +51,7 @@ const PageRoutes = () => { }> - } /> + } /> } /> } /> void +}; + +const Icon = ({ handleClick }: Props) => { + const { logo } = useAppSelector(store => store.server); + if (!logo) return null; + return ; +}; + +export default Icon; diff --git a/src/widget/Popup/Header.tsx b/src/widget/Popup/Header.tsx new file mode 100644 index 00000000..26d57a95 --- /dev/null +++ b/src/widget/Popup/Header.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { useAppSelector } from '../../app/store'; +import IconClose from './close.svg'; + +type Props = { + handleClose: () => void; +}; + +const Index: FC = ({ handleClose }) => { + const { name, logo } = useAppSelector(store => store.server); + return ( +
+
+ logo +
+
+ {name} + {/* {online ? 'Active now' : 'Offline now'} */} +
+ +
+ ); +}; + +export default Index; diff --git a/src/widget/Popup/Line.tsx b/src/widget/Popup/Line.tsx new file mode 100644 index 00000000..97076a85 --- /dev/null +++ b/src/widget/Popup/Line.tsx @@ -0,0 +1,14 @@ +import clsx from 'clsx'; + +type Props = { + thin?: boolean +}; + +const Index = ({ thin = false }: Props) => { + const h = thin ? 'h-[1px]' : 'h-0.5'; + return ( +
+ ); +}; + +export default Index; diff --git a/src/widget/Popup/MessageFeed/Message/Text.tsx b/src/widget/Popup/MessageFeed/Message/Text.tsx new file mode 100644 index 00000000..c3cc2850 --- /dev/null +++ b/src/widget/Popup/MessageFeed/Message/Text.tsx @@ -0,0 +1,81 @@ +import { FC, memo } from 'react'; +import clsx from 'clsx'; +import { MessagePayload } from '../../../../app/slices/message'; +// import { Message } from '../../../../types/chatbot'; +// import Image from 'next/image'; +import { useAppSelector } from '../../../../app/store'; +import { getInitials, getInitialsAvatar } from '../../../../common/utils'; + +type TimeProps = { + time: number; +}; + +const Time: FC = ({ time }) => { + return ( + + ); +}; + +export type MessageProps = { + compact?: boolean; + isFirst?: boolean; +} & MessagePayload; + +const Index: FC = (props) => { + const { server: { name, logo }, loginUser } = useAppSelector(store => { return { server: store.server, loginUser: store.authData.user }; }); + const { from_uid, content, created_at, compact, isFirst = false } = props; + // 暂时写死 + const isHost = from_uid == 304; + return ( +
+ {!compact && ( +
+ {isHost ? ( + logo + ) : ( + avatar + )} +
+ )} + {!compact && ( + + {isHost ? name : loginUser?.name} + + )} +

+ {compact && ( + + )} + {content} +

+
+ ); +}; + +export default memo(Index, (prev, next) => { + return prev.mid === next.mid; +}); diff --git a/src/widget/Popup/MessageFeed/index.tsx b/src/widget/Popup/MessageFeed/index.tsx new file mode 100644 index 00000000..4aa3c10f --- /dev/null +++ b/src/widget/Popup/MessageFeed/index.tsx @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/indent */ +import { FC, memo } from 'react'; +// import { MessagePayload } from '../../../app/slices/message'; +import { useAppSelector } from '../../../app/store'; +import TextMessage from './Message/Text'; + +type Props = { + uid: number +}; + +// 间隔10秒 +const interval = 10 * 1000; +const Index: FC = ({ uid }) => { + const { mids, messageMap } = useAppSelector(store => { return { mids: store.userMessage.byId[uid], messageMap: store.message }; }); + console.log("mids", mids, uid); + + if (!mids) return null; + return mids.map((mid, idx) => { + const currMsg = messageMap[mid]; + const prevMsg = messageMap[mids[idx - 1]]; + const compact = !prevMsg + ? false + : prevMsg.from_uid !== currMsg.from_uid + ? false + : (currMsg.created_at ?? 0) - (prevMsg.created_at ?? 0) < interval; + return ; + }); +}; + +export default memo(Index, (prev, next) => { + return JSON.stringify(prev.uid) === JSON.stringify(next.uid); +}); diff --git a/src/widget/Popup/Welcome.tsx b/src/widget/Popup/Welcome.tsx new file mode 100644 index 00000000..17e78710 --- /dev/null +++ b/src/widget/Popup/Welcome.tsx @@ -0,0 +1,30 @@ +// import clsx from 'clsx' +import { memo } from 'react'; +import { useAppSelector } from '../../app/store'; + +const TextMessage = ({ text, animate = '' }: { text: string; animate?: string }) => ( +

{text}

+); +const Index = () => { + const { name, logo } = useAppSelector(store => store.server); + return ( +
+
+ logo +
+
+ + {name} + + + +
+
+ ); +}; + +export default memo(Index); diff --git a/src/widget/Popup/close.svg b/src/widget/Popup/close.svg new file mode 100644 index 00000000..0895fc0f --- /dev/null +++ b/src/widget/Popup/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/widget/Popup/index.tsx b/src/widget/Popup/index.tsx new file mode 100644 index 00000000..2999ed11 --- /dev/null +++ b/src/widget/Popup/index.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect, useRef, ChangeEvent } from 'react'; +import Header from './Header'; +import Welcome from './Welcome'; +import MessageFeed from './MessageFeed'; +import Line from './Line'; +import useSendMessage from '../../common/hook/useSendMessage'; +import { useAppSelector } from '../../app/store'; +type Props = { + handleClose: () => void +} + +const Index = ({ handleClose }: Props) => { + const messageListRef = useRef(null); + const loginUid = useAppSelector(store => store.authData.user?.uid); + const { sendMessage, isSuccess } = useSendMessage({ + from: loginUid, + to: 304, + context: "user" + }); + // const { checked, firstEnter, session } = useSession({ preCheck: true }); + // const { isSending, sendSuccess, sendMessage, message } = useSendMessage(); + // const { appendMessage, messages } = useFeed(); + // const messageListRef = useRef(null); + const [input, setInput] = useState(''); + const handleInput = (evt: ChangeEvent) => { + setInput(evt.target.value); + }; + const handleSend = () => { + if (!input) return; + sendMessage({ + type: "text", + content: input + }); + setInput(""); + }; + + // 自动滚动到底部,todo:根据距离底部位置大小自动滚动 类似微信体验 + useEffect(() => { + const container = messageListRef.current; + if (container) { + setTimeout(() => { + container.scrollTop = container.scrollHeight; + }, 30); + } + }, [isSuccess]); + return ( +