この記事でやること
チャットアプリで地味にストレスになるURL共有と未読管理を改善します。
- 長いURL貼り付け時の自動変換ダイアログ(
onPasteではなくonChangeで検出する理由) - Markdownリンクのレンダリング
- スクロール位置に基づく未読管理
- チャンネル切り替え時の状態リセット
URL自動変換ダイアログ
課題
80文字超のURLがメッセージ1行を占有して読みにくい。Markdown [テキスト](URL) を毎回手書きするのは面倒。
仕様
40文字超のURLを検出 → ダイアログ表示 → 表示テキスト入力 → Markdown形式に変換。
onChangeでのURL検出
const inputRef = useRef<string>("")
const [linkDialogUrl, setLinkDialogUrl] = useState<string | null>(null)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const prevValue = inputRef.current
const added = newValue.length > prevValue.length
? newValue.slice(prevValue.length)
: ""
const urlPattern = /https?:\/\/[^\s]+/
const match = added.match(urlPattern)
if (match && match[0].length > 40) {
setLinkDialogUrl(match[0])
setShowLinkDialog(true)
}
inputRef.current = newValue
setText(newValue)
}
なぜonPasteではなくonChangeか
最初はonPasteで実装しました。
// NG: iOSで不安定
const handlePaste = (e: React.ClipboardEvent) => {
const pasted = e.clipboardData.getData("text")
if (isLongUrl(pasted)) {
setShowLinkDialog(true)
}
}
iOSのSafariでonPasteが発火しないケースがあります。
- キーボードの「ペースト」ボタン → 発火する
- テキスト長押しメニューの「ペースト」→ 発火しないことがある
- ユニバーサルクリップボード → 発火しない
onChangeで前回値との差分を見る方式なら、入力方法に関係なくURLを検出できます。
リンク変換処理
const applyLink = (displayText: string) => {
if (!linkDialogUrl) return
const markdown = displayText
? `[${displayText}](${linkDialogUrl})`
: linkDialogUrl
const newText = text.replace(linkDialogUrl, markdown)
setText(newText)
inputRef.current = newText
setShowLinkDialog(false)
setLinkDialogUrl(null)
}
Markdownリンクのレンダリング
メッセージ表示側で[テキスト](URL)をリンクに変換。
const renderMessageText = (text: string): React.ReactNode[] => {
const parts: React.ReactNode[] = []
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = linkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
}
parts.push(
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 underline hover:text-primary-700"
>
{match[1]}
</a>
)
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
}
return parts
}
未読管理の改善
課題
- 見ているのに未読バッジが消えない
- スクロール上部にいるのに新着が自動既読される
isAtBottomフラグ
スクロール位置で既読判定を分岐。
const [isAtBottom, setIsAtBottom] = useState(true)
const [unreadCount, setUnreadCount] = useState(0)
const [showScrollButton, setShowScrollButton] = useState(false)
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
const atBottom = scrollHeight - scrollTop - clientHeight < 50
setIsAtBottom(atBottom)
if (atBottom) {
setUnreadCount(0)
setShowScrollButton(false)
markAllAsRead()
}
}
新着メッセージの処理
const handleNewMessage = (message: Message) => {
if (isAtBottom) {
markAsRead(message.id)
scrollToBottom()
} else {
setUnreadCount((prev) => prev + 1)
setShowScrollButton(true)
}
}
「↓新着N件」ボタン
{showScrollButton && (
<button
onClick={() => {
scrollToBottom()
setUnreadCount(0)
setShowScrollButton(false)
markAllAsRead()
}}
className="fixed bottom-20 right-4 bg-primary-500 text-white
px-4 py-2 rounded-full shadow-lg z-30"
>
↓ 新着 {unreadCount}件
</button>
)}
チャンネル切り替え時のリセット
一番ハマったバグ。前のチャンネルのisAtBottom = falseが新しいチャンネルに引き継がれ、未読バッジが消えない。
useEffect(() => {
setIsAtBottom(true)
setUnreadCount(0)
setShowScrollButton(false)
scrollToBottom()
}, [currentChannelId])
チャンネルIDが変わったら、スクロール関連の状態は全てリセット。 チャット系アプリでは定番のバグパターンです。
まとめ
| 改善 | ポイント |
|---|---|
| URL自動変換 |
onChange差分検出(iOS onPaste問題回避) |
| Markdownリンク | 正規表現でパース + Reactノード変換 |
| 未読管理 |
isAtBottomで既読判定を分岐 |
| チャンネル切替 | 状態リセット漏れに注意 |
SEOスコアチェックツール: SEO_CHECK — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work