refactor: new widget UX

This commit is contained in:
Tristan Yang
2022-11-11 11:42:20 +08:00
parent 46a4e150e6
commit 821c1d1112
23 changed files with 1155 additions and 1643 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ export const onMessageSendStarted = async (
// console.log("message server mid", server_mid);
batch(() => {
dispatch(removeContextMessage({ id, mid: ts }));
dispatch(addMessage({ mid: server_mid, ...tmpMsg }));
dispatch(addMessage({ mid: server_mid, ...tmpMsg, sending: false }));
dispatch(addContextMessage({ id, mid: server_mid }));
});
setTimeout(() => {
@@ -5,7 +5,6 @@ import Button from "./styled/Button";
const StyledSocialButton = styled(Button)`
width: 100%;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
+1 -2
View File
@@ -10,11 +10,10 @@ import { useLoginMutation } from "../../app/services/auth";
const StyledSocialButton = styled(Button)`
position: relative;
width: 100%;
margin-bottom: 16px;
background: white !important;
border: 1px solid #d0d5dd;
color: #344054;
height: 42px;
height: 46px;
overflow: hidden;
.mask {
background: inherit;
+8 -5
View File
@@ -156,11 +156,14 @@ export default function LoginPage() {
</Button>
</form>
{hasDivider && <hr className="or" />}
{enableMagicLink && <MagicLinkLogin />}
{googleLogin && <GoogleLoginButton clientId={clientId} />}
{enableGithubLogin && <GithubLoginButton client_id={githubAuthConfig?.client_id} />}
{enableMetamaskLogin && <MetamaskLoginButton login={login} />}
{oidc.length > 0 && <OidcLoginButton issuers={oidc} />}
<div className="btns">
{enableMagicLink && <MagicLinkLogin />}
{googleLogin && <GoogleLoginButton clientId={clientId} />}
{enableGithubLogin && <GithubLoginButton client_id={githubAuthConfig?.client_id} />}
{enableMetamaskLogin && <MetamaskLoginButton login={login} />}
{oidc.length > 0 && <OidcLoginButton issuers={oidc} />}
</div>
{whoCanSignUp === "EveryOne" && <SignUpLink />}
</div>
</StyledWrapper>
+5
View File
@@ -76,6 +76,11 @@ const StyledWrapper = styled.div`
color: #667085;
}
}
>.btns{
display: flex;
flex-direction: column;
gap: 15px;
}
}
`;
+5 -9
View File
@@ -27,7 +27,7 @@ export default function Widget() {
Code Example:
</label>
<SyntaxHighlighter id="code" language="html" style={vscDarkPlus} className="rounded">
{`<!-- put this code snippet into your html file -->\n<script \n data-host-id="4" \n data-origin="${location.origin}" \n data-close-width="52" \n data-close-height="52" \n data-open-width="600" \n data-open-height="800" \n src="${location.origin}/widget.js" \n async \n/>`}
{`<!-- put this code snippet into your html file -->\n<script \n data-host-id="4" \n data-close-width="48" \n data-close-height="48" \n data-open-width="380" \n data-open-height="680" \n src="${location.origin}/widget.js" \n async \n/>`}
</SyntaxHighlighter>
<div className="text-gray-500 text-sm mt-5 mb-2">
Configuration Description:
@@ -46,25 +46,21 @@ export default function Widget() {
paramKey: "host-id",
paramDefault: 1,
remarks: "Assign the user chatting with visitor"
}, {
paramKey: "origin",
paramDefault: 'location.origin',
remarks: "The domain (with protocol) script load from"
}, {
paramKey: "close-width",
paramDefault: `52(px)`,
paramDefault: `48(px)`,
remarks: "The width while widget closed"
}, {
paramKey: "close-height",
paramDefault: `52(px)`,
paramDefault: `48(px)`,
remarks: "The height while widget closed"
}, {
paramKey: "open-width",
paramDefault: `600(px)`,
paramDefault: `380(px)`,
remarks: "The width while widget opened"
}, {
paramKey: "open-height",
paramDefault: `800(px)`,
paramDefault: `680(px)`,
remarks: "The height while widget opened"
}
].map(row => <Row key={row.paramKey} {...row} />)}
+13
View File
@@ -0,0 +1,13 @@
import { FC } from 'react';
type Props = {
};
const Index: FC<Props> = () => {
return (
<footer className="text-xs text-gray-300 text-center pb-2">Powered by <a href="https://voce.chat" target="_blank" rel="noopener noreferrer" className="text-gray-400">voce.chat</a></footer>
);
};
export default Index;
+10 -8
View File
@@ -7,18 +7,20 @@ type Props = {
};
const Index: FC<Props> = ({ handleClose }) => {
const { name, description, logo } = useAppSelector(store => store.server);
const { name, logo } = useAppSelector(store => store.server);
return (
<div className="flex gap-2 justify-between items-center p-2">
<div className="relative w-12 h-12">
<header className="relative flex justify-between items-center h-14 px-4 bg-[#1fe1f9]">
<div className="relative w-8 h-8">
<img src={logo} alt="logo" className="w-full h-full rounded-full" />
</div>
<div className="flex flex-col flex-1">
<span className="text-gray-900 text-lg font-bold">{name}</span>
{description && <span className="text-gray-400 text-sm">{description}</span>}
<div className="flex-1 px-4 pr-2 text-lg">
<span className="text-lg font-bold truncate text-gray-50">{name}</span>
</div>
<IconClose className="w-5 h-5 mr-2 cursor-pointer" onClick={handleClose} />
</div>
<button type='button' className='w-6 h-6'>
<IconClose onClick={handleClose} />
</button>
</header>
);
};
-14
View File
@@ -1,14 +0,0 @@
import clsx from 'clsx';
type Props = {
thin?: boolean
};
const Index = ({ thin = false }: Props) => {
const h = thin ? 'h-[1px]' : 'h-0.5';
return (
<hr className={clsx('w-full bg-gray-300 border-none', h)} />
);
};
export default Index;
+1 -1
View File
@@ -20,7 +20,7 @@ const Login = () => {
} = loginConfig;
const googleLogin = enableGoogleLogin && clientId;
return (
<div className="w-60 flex flex-col py-2">
<div className="w-60 flex flex-col gap-2 mt-4">
{googleLogin && <GoogleLoginButton clientId={clientId} />}
{enableGithubLogin && <GithubLoginButton client_id={githubAuthConfig?.client_id} source="widget" />}
</div>
+24
View File
@@ -0,0 +1,24 @@
// import React from 'react';
import clsx from 'clsx';
type Props = {
uid: number,
host?: boolean,
content: string,
sending: boolean
}
const Text = ({ content, host, sending }: Props) => {
return host ?
<div className="text-md text-gray-900 bg-gray-100 rounded-lg px-3 py-1.5 break-words" style={{ maxWidth: 'min(((100vw - 56px) - 20px) - 64px, 360px)' }}>
{content}
</div>
:
<div className={clsx("text-md text-white bg-[#1fe1f9] rounded-lg px-3 py-1.5 transition-all break-words", sending ? 'opacity-70' : "")} style={{ maxWidth: 'min(((100vw - 56px) - 20px) - 64px, 360px)' }}>
{content}
</div>
;
};
export default Text;
+52
View File
@@ -0,0 +1,52 @@
import { memo } from 'react';
import dayjs from 'dayjs';
import clsx from 'clsx';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(localizedFormat);
import Text from './Text';
import { useAppSelector } from '../../../app/store';
export interface IWidgetMessage {
mid: number,
uid: number,
host?: boolean,
type?: "text",
content: string,
create_time: number,
sending: boolean
}
const Time = ({ time }: { time: number }) => {
return <time itemProp='dateCreated' dateTime={new Date(time).toISOString()} className='hidden group-hover:inline px-1.5 text-gray-500 text-xs'>
{dayjs.unix(time / 1000).format('LT')}
</time>;
};
const Index = (props: IWidgetMessage) => {
const { logo } = useAppSelector(store => store.server);
const { host = false, type = "text", content, uid, create_time, sending } = props;
let contentContainer = null;
switch (type) {
case "text":
contentContainer = <Text sending={sending} content={content} host={host} uid={uid} />;
break;
default:
break;
}
return <div className={clsx('group flex mb-3', host ? 'relative justify-start items-start' : 'items-center justify-end px-3')}>
{host ? <><div className="w-9 h-9 absolute top-0 left-3">
<img src={logo} alt="avatar" className='rounded-full bg-gray-100 w-9 h-9' />
</div>
<div className="pl-14 flex items-center">
{contentContainer}
<Time time={create_time} />
</div>
</> : <>
<Time time={create_time} />
{contentContainer}
</>}
</div>;
};
export default memo(Index, (prev, next) => {
return prev.mid === next.mid;
});
+66
View File
@@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/indent */
import { useEffect, memo, useRef, useState } from 'react';
import { useAppSelector } from '../../app/store';
import Message from './Message';
type Props = {
hostId: number
};
const triggerScrollHeight = 1200;
const MessageFeed = ({ hostId }: Props) => {
const firstRender = useRef(true);
const [hasNewMsg, setHasNewMsg] = useState(false);
const { mids, messageMap } = useAppSelector(store => { return { mids: store.userMessage.byId[hostId], messageMap: store.message }; });
console.log("mids", mids, hostId);
useEffect(() => {
const container = document.querySelector("#MESSAGE_LIST_CONTAINER");
if (container && mids && mids.length > 0) {
const lastMsg = messageMap[mids[mids.length - 1]];
const lastIsMyself = lastMsg.from_uid != hostId;
const { scrollHeight, scrollTop } = container;
const deltaHeight = scrollHeight - scrollTop;
console.log("ttt", deltaHeight, lastIsMyself, firstRender.current);
// 自己发送的消息
if (lastIsMyself && lastMsg.sending) {
container.scrollTop = container.scrollHeight;
}
// 在可视区域附近
if (deltaHeight <= triggerScrollHeight) {
container.scrollTop = container.scrollHeight;
setHasNewMsg(false);
} else if (!lastIsMyself && !firstRender.current) {
console.log("new msg");
setHasNewMsg(true);
}
}
firstRender.current = false;
}, [mids, messageMap, hostId]);
const handleNewMessageScroll = () => {
const container = document.querySelector("#MESSAGE_LIST_CONTAINER");
if (container) {
setHasNewMsg(false);
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 30);
}
};
if (!mids) return null;
return <>
{mids.map((mid) => {
const currMsg = messageMap[mid];
if (!currMsg) return null;
const lastMsg = messageMap[mids[mids.length - 1]];
console.log("lll", lastMsg);
const { content, created_at = 0, from_uid = 0, sending = false } = currMsg;
return <Message uid={from_uid} host={hostId == from_uid} sending={sending} key={mid} mid={mid} type="text" content={content} create_time={created_at} />;
})}
{hasNewMsg && <button type='button' className="sticky bottom-4 left-1/2 -translate-x-1/2 bg-white border border-orange-300 px-2 py-1.5 text-xs text-orange-500 rounded-lg" onClick={handleNewMessageScroll} >New Message</button>}
</>;
};
// export default MessageFeed;
export default memo(MessageFeed, (prev, next) => {
return prev.hostId === next.hostId;
});
@@ -1,81 +0,0 @@
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<TimeProps> = ({ time }) => {
return (
<time className="text-gray-300 text-sm">{`${new Date(time).toLocaleTimeString('en-US', {
minute: 'numeric',
hour: 'numeric',
hour12: true,
})}`}</time>
);
};
export type MessageProps = {
hostId: number;
compact?: boolean;
isFirst?: boolean;
} & MessagePayload;
const Index: FC<MessageProps> = (props) => {
const { server: { name, logo }, loginUser } = useAppSelector(store => { return { server: store.server, loginUser: store.authData.user }; });
const { hostId, from_uid, content, created_at = 0, compact, isFirst = false } = props;
const isHost = from_uid == hostId;
return (
<div
className={clsx(
'flex flex-col relative pl-14 py-2 hover:bg-gray-100 rounded-sm',
compact ? 'py-1' : 'mt-4',
isFirst ? 'mt-0' : ''
)}
>
{!compact && (
<div className="absolute left-1 top-2 w-12 h-12 rounded-full overflow-hidden">
{isHost ? (
<img src={logo} alt="logo" className="w-full h-full" />
) : (
<img className="w-12 h-12" src={getInitialsAvatar({ initials: getInitials(loginUser?.name ?? "Unkown") })} alt="avatar" />
)}
</div>
)}
{!compact && (
<span className="flex items-center gap-2 text-md">
<em className="text-black not-italic font-bold">{isHost ? name : loginUser?.name}</em>
<Time time={created_at} />
</span>
)}
<p
className={clsx(
'text-gray-600 text-md whitespace-normal break-words w-[80%]',
compact ? 'group relative ' : 'pt-1'
)}
>
{compact && (
<time className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-gray-300 text-xs invisible group-hover:visible">{`${new Date(created_at)
.toLocaleTimeString('en-US', {
second: 'numeric',
minute: 'numeric',
hour: 'numeric',
hour12: true,
})
.split(' ')[0]
}`}</time>
)}
{content}
</p>
</div>
);
};
export default memo(Index, (prev, next) => {
return prev.mid === next.mid;
});
-40
View File
@@ -1,40 +0,0 @@
/* eslint-disable @typescript-eslint/indent */
import { FC, memo, useEffect } 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<Props> = ({ uid }) => {
const { mids, messageMap } = useAppSelector(store => { return { mids: store.userMessage.byId[uid], messageMap: store.message }; });
console.log("mids", mids, uid);
useEffect(() => {
const container = document.querySelector("#MESSAGE_LIST_CONTAINER");
if (mids && mids.length > 0 && container) {
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 30);
}
}, [mids]);
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 <TextMessage hostId={uid} key={currMsg.mid} {...currMsg} compact={compact} isFirst={idx === 0} />;
});
};
export default memo(Index, (prev, next) => {
return JSON.stringify(prev.uid) === JSON.stringify(next.uid);
});
+62
View File
@@ -0,0 +1,62 @@
import { useRef, useState, memo } from 'react';
import clsx from 'clsx';
import useSendMessage from '../../common/hook/useSendMessage';
const textareaClassName = clsx(
'px-2.5 py-1.5 text-sm rounded-md w-full block',
'min-h-[32px] max-h-[92px] h-8 resize-none overflow-y-auto',
'ring-1 ring-gray-200 focus:ring-2 focus:ring-[#1fe1f9]',
'focus:outline-none',
);
type Props = {
from: number,
to: number
}
const MessageInput = (props: Props) => {
const { from, to } = props;
const { sendMessage } = useSendMessage({
from,
to,
context: "user"
});
const [content, setContent] = useState('');
const ref = useRef<HTMLTextAreaElement>(null);
return (
<div className="relative border-t border-gray-300 w-full">
<div className={'px-3 py-2 min-h-[48px]'}>
<textarea
// disabled={isSending}
ref={ref}
maxLength={4096}
className={textareaClassName}
value={content}
placeholder="Type and press enter"
onChange={e => setContent(e.target.value)}
onInput={() => {
const element = ref.current;
if (!element) return;
element.style.height = '32px';
// borderTop + borderBottom = 2px
element.style.height = `${element.scrollHeight + 2}px`;
}}
onKeyDown={e => {
if (!e.shiftKey && e.key === 'Enter') {
// e.stopPropagation();
e.preventDefault();
if (content.trim().length === 0) return;
sendMessage({
type: "text",
content
});
setContent("");
}
}}
/>
</div>
</div>
);
};
export default memo(MessageInput);
+15 -23
View File
@@ -3,37 +3,29 @@ import { memo } from 'react';
import { useAppSelector } from '../../app/store';
import Login from './Login';
const TextMessage = ({ text, animate = '' }: { text: string; animate?: string }) => (
<p className={`text-gray-600 text-md mb-1 ${animate}`}>{text}</p>
);
type Props = {
needLogin?: boolean
}
const Index = ({ needLogin = false }: Props) => {
const { name, logo } = useAppSelector(store => store.server);
return (
<div className="flex gap-1 pl-1">
<div className="w-12 h-12">
<img src={logo} alt="logo" className="w-full h-full rounded-full" />
</div>
<div className="flex flex-col">
<span className="flex items-center gap-2 leading-6">
<em className="text-black not-italic text-lg font-bold">{name}</em>
<time className="text-gray-300 text-sm">{`${new Date().toLocaleTimeString('en-US', {
minute: 'numeric',
hour: 'numeric',
hour12: true,
})}`}</time>
</span>
<TextMessage text="Hi there, Nice to meet you! 👋👋👋" animate={needLogin ? "animate-[fadeInUp_.5s_ease-in-out_both]" : ''} />
{needLogin && <>
<TextMessage text="You need login before we have a nice talk 👇" animate="animate-[fadeInUp_.5s_.8s_ease-in-out_both]" />
<div className="animate-[fadeInUp_.5s_1.2s_ease-in-out_both]">
<Login />
<>
<div className="group relative flex justify-start items-start mb-3">
<div className="w-9 h-9 absolute top-0 left-3">
<img src={logo} alt="logo" className="rounded-full bg-transparent w-9 h-9" />
</div>
<div className="pl-14">
<div className="rounded-lg" style={{ maxWidth: 'min(((100vw - 56px) - 20px) - 64px, 360px)' }}>
<div className="text-md text-gray-900 px-3 py-1.5 bg-gray-100 rounded-lg mb-1.5">
👋 Hi there, Nice to meet you! Please login before we have a nice talk 👇
</div>
</div>
</>}
</div>
</div>
</div>
{needLogin && <div className="pl-14 animate-[fadeInUp_.5s_1.2s_ease-in-out_both]">
<Login />
</div>}
</>
);
};
+1 -3
View File
@@ -1,3 +1 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.25 5.75L5.75 16.25M5.75 5.75L16.25 16.25" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 0 24 24" width="18" fill="#fff" class="w-6 h-6"><path d="M0 0h24v24H0z" fill="none"></path><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg>

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 279 B

+28 -50
View File
@@ -1,9 +1,9 @@
import { useState, ChangeEvent } from 'react';
// import { useState, ChangeEvent } from 'react';
import Header from './Header';
import Footer from './Footer';
import Welcome from './Welcome';
import MessageFeed from './MessageFeed';
import Line from './Line';
import useSendMessage from '../../common/hook/useSendMessage';
import MessageInput from './MessageInput';
import { useAppSelector } from '../../app/store';
type Props = {
hostId: number,
@@ -12,61 +12,39 @@ type Props = {
const Index = ({ handleClose, hostId }: Props) => {
const { user: loginUser, token, guest: isGuest } = useAppSelector(store => store.authData);
const { sendMessage } = useSendMessage({
from: loginUser?.uid,
to: hostId,
context: "user"
});
const [input, setInput] = useState('');
const handleInput = (evt: ChangeEvent<HTMLTextAreaElement>) => {
setInput(evt.target.value);
};
const handleSend = () => {
if (!input) return;
sendMessage({
type: "text",
content: input
});
setInput("");
};
// const { sendMessage } = useSendMessage({
// from: loginUser?.uid,
// to: hostId,
// context: "user"
// });
// const [input, setInput] = useState('');
// const handleInput = (evt: ChangeEvent<HTMLTextAreaElement>) => {
// setInput(evt.target.value);
// };
// const handleSend = () => {
// if (!input) return;
// sendMessage({
// type: "text",
// content: input
// });
// setInput("");
// };
// no token or guest login
const notLogin = !token || isGuest;
return (
<aside className="flex flex-col justify-between bg-white w-[600px] h-[800px] rounded-md overflow-hidden">
<aside className="flex flex-col bg-white w-full h-full rounded-md overflow-hidden">
<Header handleClose={handleClose} />
<Line />
{/* message list */}
<section id='MESSAGE_LIST_CONTAINER' className="px-2 py-3 flex-1 overflow-y-auto scroll-smooth">
<main id='MESSAGE_LIST_CONTAINER' className="relative flex-1 overflow-y-auto scroll-smooth">
{/* placeholder */}
<div className="flex items-center h-10 justify-center"></div>
<Welcome needLogin={notLogin} />
{notLogin ? null : <MessageFeed uid={hostId} />}
</section>
<Line />
{notLogin ? null : <MessageFeed hostId={hostId} />}
</main>
{/* message input */}
<div className="w-full px-2 py-3">
<textarea
disabled={notLogin}
value={input}
onChange={handleInput}
className="w-full h-full text-sm p-2 rounded-lg bg-gray-200 resize-none text-black outline-none"
placeholder={notLogin ? "Login first..." : "Write a message..."}
rows={3}
/>
</div>
<Line thin />
{/* operation area */}
<div className="w-full flex px-3 py-2 justify-between">
<div className="opts">{/* opts placeholder */}</div>
<button
type="button"
onClick={handleSend}
disabled={input.trim() === ''}
className="rounded-full bg-[#FA491D] disabled:bg-gray-200 text-white disabled:text-gray-400 text-xs leading-4 px-2 py-1.5 "
>
Send
</button>
</div>
<span className="text-sm text-gray-300 text-center pb-2">Powered by <a href="https://voce.chat" target="_blank" rel="noopener noreferrer" className="text-gray-400">voce.chat</a></span>
{notLogin ? null : <MessageInput from={loginUser?.uid || 0} to={hostId} />}
<Footer />
</aside>
);
};