Skip to content

Commit ccdcce8

Browse files
Improvements to mobile textarea input
1 parent a0e3259 commit ccdcce8

6 files changed

Lines changed: 67 additions & 19 deletions

File tree

‎src/components/layout/ChatArea.tsx‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { platform } from "@tauri-apps/plugin-os";
22
import type { EmojiClickData } from "emoji-picker-react";
33
import type * as React from "react";
4-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
import {
5+
useCallback,
6+
useEffect,
7+
useLayoutEffect,
8+
useMemo,
9+
useRef,
10+
useState,
11+
} from "react";
512
import { createPortal } from "react-dom";
613
import { FaGift, FaList, FaPlus, FaTimes } from "react-icons/fa";
714
import { v4 as uuidv4 } from "uuid";
@@ -166,21 +173,36 @@ export const ChatArea: React.FC<{
166173
// user first scrolled up. Prevents mass DOM insertion at the top when isScrolledUp becomes true,
167174
// which caused WKWebView scroll anchoring to fail and the viewport to jump to the top.
168175
const scrollUpStartRef = useRef<number | null>(null);
176+
// lineHeight/padding are static CSS values — cache them after first read to avoid
177+
// getComputedStyle on every keystroke, which forces layout flush on mobile WebView.
178+
const textareaMetricsRef = useRef<{
179+
lineHeight: number;
180+
paddingTop: number;
181+
paddingBottom: number;
182+
} | null>(null);
169183

170184
const resizeTextarea = useCallback(() => {
171185
const textarea = inputRef.current;
172186
if (!textarea) return;
173-
textarea.style.height = "auto";
174-
const computed = window.getComputedStyle(textarea);
175-
const lineHeight = Number.parseFloat(computed.lineHeight) || 24;
176-
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
177-
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
187+
188+
if (!textareaMetricsRef.current) {
189+
const computed = window.getComputedStyle(textarea);
190+
textareaMetricsRef.current = {
191+
lineHeight: Number.parseFloat(computed.lineHeight) || 24,
192+
paddingTop: Number.parseFloat(computed.paddingTop) || 0,
193+
paddingBottom: Number.parseFloat(computed.paddingBottom) || 0,
194+
};
195+
}
196+
197+
const { lineHeight, paddingTop, paddingBottom } =
198+
textareaMetricsRef.current;
178199
const maxHeight = lineHeight * 5 + paddingTop + paddingBottom;
200+
textarea.style.height = "auto";
179201
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
180202
}, []);
181203

182204
// biome-ignore lint/correctness/useExhaustiveDependencies: messageText is intentionally listed so the effect re-runs on every keystroke, even though it isn't referenced inside the body
183-
useEffect(() => {
205+
useLayoutEffect(() => {
184206
resizeTextarea();
185207
}, [resizeTextarea, messageText]);
186208

@@ -515,6 +537,17 @@ export const ChatArea: React.FC<{
515537
currentUser,
516538
});
517539

540+
// Memoize preview styles — selectedColor/selectedFormatting don't change while typing,
541+
// so recomputing this on every keystroke is unnecessary object churn.
542+
const previewStyle = useMemo(
543+
() =>
544+
getPreviewStyles({
545+
color: selectedColor || "inherit",
546+
formatting: selectedFormatting,
547+
}),
548+
[selectedColor, selectedFormatting],
549+
);
550+
518551
// Get messages for current channel or private chat - memoized
519552
const channelKey = useMemo(
520553
() =>
@@ -1335,8 +1368,10 @@ export const ChatArea: React.FC<{
13351368
const handleOpenPM = (username: string) => {
13361369
if (selectedServerId) {
13371370
openPrivateChat(selectedServerId, username);
1338-
// Find and select the private chat
1339-
const server = servers.find((s) => s.id === selectedServerId);
1371+
// Read fresh store state so newly-created DMs are visible (stale closure fix)
1372+
const server = useStore
1373+
.getState()
1374+
.servers.find((s) => s.id === selectedServerId);
13401375
const privateChat = server?.privateChats?.find(
13411376
(pc) => pc.username === username,
13421377
);
@@ -1776,6 +1811,9 @@ export const ChatArea: React.FC<{
17761811
onClick={handleInputClick}
17771812
onKeyUp={handleInputKeyUp}
17781813
onKeyDown={handleKeyDown}
1814+
autoCorrect="on"
1815+
autoCapitalize="sentences"
1816+
spellCheck={true}
17791817
placeholder={
17801818
selectedChannel
17811819
? `Message #${selectedChannel.name.replace(/^#/, "")}${
@@ -1805,10 +1843,7 @@ export const ChatArea: React.FC<{
18051843
: "send"
18061844
}
18071845
className="bg-transparent border-none outline-none py-3 flex-grow text-discord-text-normal resize-none min-h-[44px] overflow-y-auto placeholder:truncate"
1808-
style={getPreviewStyles({
1809-
color: selectedColor || "inherit",
1810-
formatting: selectedFormatting,
1811-
})}
1846+
style={previewStyle}
18121847
rows={1}
18131848
/>
18141849
<InputToolbar
@@ -1827,6 +1862,7 @@ export const ChatArea: React.FC<{
18271862
onSendClick={handleSendMessage}
18281863
showSendButton={isMobile}
18291864
hideEmoji={isNativeMobile}
1865+
hasText={messageText.trim().length > 0}
18301866
/>
18311867
</div>
18321868

‎src/components/layout/MemberList.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,10 @@ export const MemberList: React.FC = () => {
508508
const handleOpenPM = (username: string) => {
509509
if (selectedServerId) {
510510
openPrivateChat(selectedServerId, username);
511-
// Find and select the private chat
512-
const server = servers.find((s) => s.id === selectedServerId);
511+
// Read fresh store state so newly-created DMs are visible (stale closure fix)
512+
const server = useStore
513+
.getState()
514+
.servers.find((s) => s.id === selectedServerId);
513515
const privateChat = server?.privateChats?.find(
514516
(pc) => pc.username === username,
515517
);

‎src/components/ui/AddPrivateChatModal.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export const AddPrivateChatModal: React.FC<AddPrivateChatModalProps> = ({
5454

5555
const handleUserSelect = (username: string) => {
5656
openPrivateChat(serverId, username);
57-
// Find and select the private chat
58-
const server = servers.find((s) => s.id === serverId);
57+
// Read fresh store state so newly-created DMs are visible (stale closure fix)
58+
const server = useStore.getState().servers.find((s) => s.id === serverId);
5959
const privateChat = server?.privateChats?.find(
6060
(pc) => pc.username === username,
6161
);

‎src/components/ui/InputToolbar.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface InputToolbarProps {
1111
onSendClick?: () => void;
1212
showSendButton?: boolean;
1313
hideEmoji?: boolean;
14+
hasText?: boolean;
1415
}
1516

1617
/**
@@ -24,6 +25,7 @@ export function InputToolbar({
2425
onSendClick,
2526
showSendButton = false,
2627
hideEmoji = false,
28+
hasText = false,
2729
}: InputToolbarProps) {
2830
return (
2931
<div className="flex items-center flex-shrink-0">
@@ -57,7 +59,12 @@ export function InputToolbar({
5759
</button>
5860
{showSendButton && (
5961
<button
60-
className="mx-1.5 w-8 h-8 rounded-full bg-discord-primary text-white flex items-center justify-center flex-shrink-0 active:bg-discord-primary/80"
62+
className={`mx-1.5 w-9 h-9 rounded-full text-white flex items-center justify-center flex-shrink-0
63+
transition-[transform,opacity,box-shadow] duration-200 ease-out active:scale-90 ${
64+
hasText
65+
? "bg-discord-primary shadow-lg shadow-discord-primary/40 scale-100 opacity-100"
66+
: "bg-discord-primary/40 scale-90 opacity-60"
67+
}`}
6168
onClick={onSendClick}
6269
// Prevent the button from stealing focus from the textarea on iOS —
6370
// without this the keyboard hides every time the send button is tapped.

‎src/components/ui/ScrollToBottomButton.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({
2222
<div className="absolute right-4">
2323
<button
2424
onClick={onClick}
25+
// Prevent focus leaving the textarea (which hides the keyboard on mobile)
26+
// while still letting the click fire and scroll to bottom.
27+
onMouseDown={(e) => e.preventDefault()}
2528
className={`bg-discord-dark-400 hover:bg-discord-dark-300 text-white rounded-full shadow-lg transition-all relative ${isMobile ? "p-3" : "p-2"}`}
2629
aria-label="Scroll to bottom"
2730
>

‎src/hooks/useChannelTabSwitching.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function useChannelTabSwitching() {
179179
if (target.type === "channel") {
180180
store.selectChannel(target.id);
181181
} else {
182-
store.selectPrivateChat(target.id);
182+
store.selectPrivateChat(target.id, { navigate: true });
183183
}
184184
};
185185

0 commit comments

Comments
 (0)