fix(web): unify mic/send button and restore scroll-down
Some checks failed
CI / test (push) Failing after 20s
Some checks failed
CI / test (push) Failing after 20s
- show one action button in composer: mic when empty, send when text exists - add floating scroll-to-bottom button in message list - exclude non-text/media messages from Chat Info links list to avoid duplicates
This commit is contained in:
@@ -61,9 +61,20 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
return members.find((m) => m.user_id === me?.id)?.role;
|
return members.find((m) => m.user_id === me?.id)?.role;
|
||||||
}, [chat?.my_role, members, me?.id]);
|
}, [chat?.my_role, members, me?.id]);
|
||||||
|
const myRoleNormalized = useMemo(() => {
|
||||||
|
if (!myRole) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const role = String(myRole).toLowerCase();
|
||||||
|
if (role === "owner" || role === "admin" || role === "member") {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [myRole]);
|
||||||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||||||
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||||||
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
||||||
|
const canEditTitle = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
||||||
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
|
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
|
||||||
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
|
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
|
||||||
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
|
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
|
||||||
@@ -259,7 +270,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
|
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
|
||||||
{isGroupLike ? (
|
{isGroupLike && canEditTitle ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-xs text-slate-400">Title</p>
|
<p className="mt-2 text-xs text-slate-400">Title</p>
|
||||||
<input
|
<input
|
||||||
@@ -321,7 +332,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
const canOpenMemberMenu =
|
const canOpenMemberMenu =
|
||||||
canManageMembers &&
|
canManageMembers &&
|
||||||
!isSelf &&
|
!isSelf &&
|
||||||
(myRole === "owner" || (myRole === "admin" && member.role === "member"));
|
(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && member.role === "member"));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded border border-slate-700/60 bg-slate-900/60 p-2 text-left hover:bg-slate-800/70"
|
className="block w-full rounded border border-slate-700/60 bg-slate-900/60 p-2 text-left hover:bg-slate-800/70"
|
||||||
@@ -652,7 +663,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
style={{ left: memberCtx.x, top: memberCtx.y }}
|
style={{ left: memberCtx.x, top: memberCtx.y }}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{myRole === "owner" && memberCtx.member.role === "member" ? (
|
{myRoleNormalized === "owner" && memberCtx.member.role === "member" ? (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -670,7 +681,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
Make admin
|
Make admin
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{myRole === "owner" && memberCtx.member.role === "admin" ? (
|
{myRoleNormalized === "owner" && memberCtx.member.role === "admin" ? (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -688,7 +699,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
Remove admin rights
|
Remove admin rights
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{myRole === "owner" && memberCtx.member.role !== "owner" ? (
|
{myRoleNormalized === "owner" && memberCtx.member.role !== "owner" ? (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -706,7 +717,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
Transfer ownership
|
Transfer ownership
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{(myRole === "owner" || (myRole === "admin" && memberCtx.member.role === "member")) ? (
|
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -869,7 +880,7 @@ function extractLinkItems(messages: Message[]): Array<{ url: string; messageId:
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const regex = /\bhttps?:\/\/[^\s<>"')]+/gi;
|
const regex = /\bhttps?:\/\/[^\s<>"')]+/gi;
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (!message.text) {
|
if (message.type !== "text" || !message.text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const links = message.text.match(regex) ?? [];
|
const links = message.text.match(regex) ?? [];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function MessageComposer() {
|
|||||||
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
||||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||||
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
|
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
|
||||||
|
const hasTextToSend = text.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordingStateRef.current = recordingState;
|
recordingStateRef.current = recordingState;
|
||||||
@@ -560,23 +561,27 @@ export function MessageComposer() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{hasTextToSend ? (
|
||||||
className="h-[42px] w-[72px] rounded-full bg-sky-500 px-4 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
|
<button
|
||||||
disabled={recordingState !== "idle"}
|
className="h-[42px] w-[56px] rounded-full bg-sky-500 px-3 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
|
||||||
onClick={handleSend}
|
disabled={recordingState !== "idle" || !activeChatId}
|
||||||
type="button"
|
onClick={handleSend}
|
||||||
>
|
type="button"
|
||||||
Send
|
>
|
||||||
</button>
|
➤
|
||||||
|
</button>
|
||||||
<button
|
) : (
|
||||||
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"}`}
|
<button
|
||||||
disabled={isUploading}
|
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${
|
||||||
onPointerDown={onMicPointerDown}
|
recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"
|
||||||
type="button"
|
}`}
|
||||||
>
|
disabled={isUploading || !activeChatId}
|
||||||
Mic
|
onPointerDown={onMicPointerDown}
|
||||||
</button>
|
type="button"
|
||||||
|
>
|
||||||
|
🎤
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export function MessageList() {
|
|||||||
const [undoTick, setUndoTick] = useState(0);
|
const [undoTick, setUndoTick] = useState(0);
|
||||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||||
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
||||||
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -143,11 +145,28 @@ export function MessageList() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [activeChatId, focusedMessageId, messages.length, setFocusedMessage]);
|
}, [activeChatId, focusedMessageId, messages.length, setFocusedMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
setShowScrollToBottom(distance > 180);
|
||||||
|
}, [messages.length, activeChatId]);
|
||||||
|
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
||||||
}
|
}
|
||||||
const chatId = activeChatId;
|
const chatId = activeChatId;
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureReactionsLoaded(messageId: number) {
|
async function ensureReactionsLoaded(messageId: number) {
|
||||||
if (reactionsByMessage[messageId]) {
|
if (reactionsByMessage[messageId]) {
|
||||||
return;
|
return;
|
||||||
@@ -325,7 +344,15 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="tg-scrollbar flex-1 overflow-auto px-2 py-4 md:px-5">
|
<div
|
||||||
|
className="tg-scrollbar flex-1 overflow-auto px-2 py-4 md:px-5"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={(event) => {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
const distance = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
setShowScrollToBottom(distance > 180);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{hasMore ? (
|
{hasMore ? (
|
||||||
<div className="mb-3 flex justify-center">
|
<div className="mb-3 flex justify-center">
|
||||||
<button
|
<button
|
||||||
@@ -466,6 +493,17 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
||||||
|
{showScrollToBottom ? (
|
||||||
|
<div className="pointer-events-none absolute bottom-4 right-4 z-40">
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-600/80 bg-slate-900/90 text-lg text-slate-100 shadow-lg hover:bg-slate-800"
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{ctx
|
{ctx
|
||||||
? createPortal(
|
? createPortal(
|
||||||
|
|||||||
Reference in New Issue
Block a user