新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)
- 基于 openim-electron-demo 改造:工号+密码登录(自研账号服务)、会话列表/聊天窗/通讯录界面重做
- 文字/语音/文件/图片消息、文件拖拽发送、表情面板、一对一语音通话(LiveKit)
- RTC 令牌对接账号服务带鉴权版 /api/rtc_token(Bearer imToken + {room,identity})
- account-service: CORS Allow-Headers 补 authorization(浏览器/渲染进程跨域调 rtc_token 需要)
- 附本地 mock 账号服务(scripts/mock-account-server.js)与截图联调脚本
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import clsx from "clsx";
|
||||
import { t } from "i18next";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import { IMessageItemProps } from ".";
|
||||
import styles from "./message-item.module.scss";
|
||||
|
||||
const PLAYED_KEY = "chaglian_played_sounds";
|
||||
|
||||
const getPlayedMap = (): Record<string, boolean> => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PLAYED_KEY) ?? "{}");
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const markPlayed = (clientMsgID: string) => {
|
||||
const map = getPlayedMap();
|
||||
map[clientMsgID] = true;
|
||||
localStorage.setItem(PLAYED_KEY, JSON.stringify(map));
|
||||
};
|
||||
|
||||
const SoundIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path
|
||||
d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15 9.2a4 4 0 0 1 0 5.6M17.5 6.8a7.4 7.4 0 0 1 0 10.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SoundMessageRender: FC<IMessageItemProps> = ({ message, isSender }) => {
|
||||
const soundElem = message.soundElem;
|
||||
const duration = Math.max(1, Math.round(soundElem?.duration ?? 0));
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [played, setPlayed] = useState(
|
||||
() => Boolean(getPlayedMap()[message.clientMsgID]),
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 宽度随时长微变:60~180px
|
||||
const width = Math.min(180, 60 + duration * 6);
|
||||
|
||||
const play = () => {
|
||||
const url = soundElem?.sourceUrl || soundElem?.soundPath;
|
||||
if (!url || playing) return;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
setPlaying(true);
|
||||
audio.onended = () => setPlaying(false);
|
||||
audio.onerror = () => {
|
||||
setPlaying(false);
|
||||
feedbackToast({ msg: t("toast.accessFailed"), error: new Error("audio error") });
|
||||
};
|
||||
audio.play().catch(() => setPlaying(false));
|
||||
markPlayed(message.clientMsgID);
|
||||
setPlayed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
styles.bubble,
|
||||
"flex cursor-pointer items-center justify-between text-sm",
|
||||
isSender && "flex-row-reverse",
|
||||
)}
|
||||
style={{ width }}
|
||||
onClick={play}
|
||||
>
|
||||
<SoundIcon className="shrink-0" />
|
||||
<span className={clsx(isSender ? "mr-2" : "ml-2")}>{duration}″</span>
|
||||
</div>
|
||||
{!isSender && !played && (
|
||||
<span className="ml-2 h-2 w-2 shrink-0 rounded-full bg-[#F4493C]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoundMessageRender;
|
||||
Reference in New Issue
Block a user