1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チームボード開発記 #5 — URL共有と未読管理を改善した実装(iOS対応の落とし穴)

1
Posted at

この記事でやること

チャットアプリで地味にストレスになる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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?