チームボード開発記 #5 — URL共有と未読管理を改善した実装(iOS対応の落とし穴)
はじめに
自作チームボード(前回記事)を実運用する中で、地味にストレスだったのがURL共有と未読管理です。
- 長いURLをそのまま貼ると読みにくい(でもMarkdownを毎回手書きするのは面倒)
- チャンネルを見ているのに未読バッジが消えない
- 逆に、スクロール上部にいるのに新着が自動既読されてしまう
本記事では、URL貼り付け時の自動変換ダイアログと、スクロール位置に基づく未読管理の実装を紹介します。iOS特有の罠も含めて。
URL貼り付け→リンク変換ダイアログ
課題
チャットでURLを共有するとき、こうなりがちです。
https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/edit?gid=0#gid=0
80文字超のURLが1行を占有して、前後のメッセージが読みにくくなります。
Markdownリンク [スプレッドシート](URL) に変換すればいいのですが、毎回手書きするのは面倒。そこで、長いURLを貼り付けたら自動でダイアログを出す仕組みを実装しました。
仕様
- テキスト入力時に40文字超のURLを検出
- 「リンクに変換しますか?」ダイアログを表示
- 表示テキストを入力 → Markdown形式に自動変換
- キャンセルすればそのまま
入力: https://very-long-url.example.com/path/to/something?param=value
↓ ダイアログで「参考資料」と入力
出力: [参考資料](https://very-long-url.example.com/path/to/something?param=value)
実装: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)
: ""
// URLパターンの検出
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を検出できます。
ダイアログUI
{showLinkDialog && linkDialogUrl && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 mx-4 max-w-sm w-full shadow-lg">
<p className="font-bold mb-2">リンクに変換</p>
<p className="text-neutral-600 mb-4 text-base break-all">
{linkDialogUrl.length > 60
? `${linkDialogUrl.slice(0, 60)}...`
: linkDialogUrl}
</p>
<input
type="text"
placeholder="表示テキストを入力"
className="w-full border rounded-lg px-3 py-2 mb-4"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
applyLink(e.currentTarget.value)
}
}}
/>
<div className="flex gap-2">
<button
onClick={() => setShowLinkDialog(false)}
className="flex-1 py-2 rounded-lg bg-neutral-100"
>
そのまま
</button>
<button
onClick={() => applyLink(displayText)}
className="flex-1 py-2 rounded-lg bg-primary-500 text-white"
>
変換
</button>
</div>
</div>
</div>
)}
リンク変換処理
const applyLink = (displayText: string) => {
if (!linkDialogUrl) return
const markdown = displayText
? `[${displayText}](${linkDialogUrl})`
: linkDialogUrl
// テキスト内のURLをMarkdownリンクに置換
const newText = text.replace(linkDialogUrl, markdown)
setText(newText)
inputRef.current = newText
setShowLinkDialog(false)
setLinkDialogUrl(null)
}
メッセージ編集時の対応
新規投稿だけでなく、メッセージ編集時にもURLを貼り付けるケースがあります。同じhandleInputChangeを共有することで、編集モードでもダイアログが機能します。
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
}
未読管理の改善
課題1:見ているのに未読が消えない
チャンネルを開いているのに、新着メッセージで未読バッジが増え続けるバグ。
原因: スクロール位置を考慮せず、「チャンネルが開いている = 既読」としていたため、最下部にいない場合に既読処理が走らない。
課題2:上部にいるのに既読になる
逆に、古いメッセージをスクロールして読んでいるときに新着が来ると、見ていないのに既読になってしまう。
解決策: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状態が残っていた。
前のチャンネルで上にスクロールした状態(isAtBottom = false)のまま新しいチャンネルに切り替えると、新しいチャンネルでも最下部にいるのに未読判定が「上部にいる」になり、未読バッジが消えない。
// チャンネル切り替え時にリセット
useEffect(() => {
setIsAtBottom(true)
setUnreadCount(0)
setShowScrollButton(false)
scrollToBottom()
}, [currentChannelId])
状態のリセット漏れは、チャット系アプリでは定番のバグパターンです。チャンネルIDが変わったら、スクロール関連の状態は全てリセットが鉄則。
まとめ
| 改善 | 実装のポイント |
|---|---|
| URL自動変換 |
onChangeで差分検出(iOSのonPaste問題回避) |
| Markdownリンク | 正規表現でパース + Reactノードに変換 |
| 未読管理 |
isAtBottomフラグで既読判定を分岐 |
| チャンネル切替 | 状態リセット漏れに注意 |
地味な改善ばかりですが、毎日使うツールではこの地味さが効く。URL共有と未読管理が安定したことで、チーム内の「あの情報どこ?」「読んだのに未読になってる」が解消されました。
筆者について: Web制作・SEOツール開発を行うフリーランス。CodeQuest.work で活動中。
SEO CHECK — ディレクターも使っているSEO診断ツールを公開中。
Discussion