0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第036回】チャット画面のノイズを極小化!マウスホバーで正確な発言時刻がふわっと浮かぶタイムスタンプ

0
Posted at

こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第36回です。

前回(第35回)は、機能追加の裏で消失していた過去ログ描画メソッドを完全復元し、単体テストによって強固なガードを敷きました。

描画パイプラインの安全が確保されたことで、私たちの関心はふたたび 「UI/UXの極限チューニング」 へと向かいました。

今回フォーカスしたのは、チャットのUI設計において古今東西のエンジニアを悩ませてきた 「発言時刻(タイムスタンプ)をどう表示すべきか?」 という問題です。

毎行に時刻を出すと画面が文字と数字で埋め尽くされてうるさい。しかし、時刻が一切出ないと「これ何分前の発言?」と混乱してしまう――。本稿では、この矛盾する二大要求を、 CSSホバーアニメーション と HTML標準の title 属性 の絶妙な組み合わせによってスマートに解決した軌跡を解説します!


1. チャットUIにおける「タイムスタンプのジレンマ」

リアルタイムチャットのUIを設計する際、時刻表示には大きく分けて2つの流派が存在します。

流派 メリット デメリット
A. 全行常時表示型
(例: Discord, Slack)
いつ発言されたかが常に分かる ・「14:23:45」が画面に何十個も並び、視覚ノイズが激しい
・画面幅が狭いと本文の領域を圧迫する
B. 完全非表示・間引き型
(例: LINEの一部, 昔のIRC)
画面がすっきりして本文に集中できる ・「今何時何分の発言に対してレスしているのか」が分からない
・話のテンポ感(5分前なのか30秒前なのか)が見失われやすい

私たちが目指したのは、IRC(XChat / HexChat)の伝統を受け継ぐ 「極限まで削ぎ落とされたクリーンな2カラムレイアウト(発言者 | 本文)」 です。
せっかく美しい2カラムを実現したのに、行頭にベタベタと時刻を固定表示してしまっては、ミニマリズムの魅力が半減してしまいます。

そこで私たちが導き出した結論が、 「普段は存在感を消し、知りたいと思った瞬間にだけふわっと浮かび上がるホバータイムスタンプ」 でした。


2. 二重構造のアーキテクチャ設計

ユーザーが時刻を知りたい場面には、「ざっくり何時何分か知りたい」ライトなケースと、「何年何月何日、秒単位まで厳密に知りたい」ケースの2種類があります。
これらを満足させるため、以下の二重構造を採用しました。

  1. 第1層:CSSフェードインバブル(HH:mm:ss) :
    • マウスが行の上を通過した瞬間に、右端に邪魔にならない半透明の等幅フォントでフェードイン。
  2. 第2層:ネイティブ title 属性(YYYY/MM/DD HH:mm:ss) :
    • 行全体に title 属性を付与しておくことで、マウスを止めた読者にOSネイティブの完全日時ツールチップを提供。

3. 実装の全貌:JavaScriptゼロ負荷のCSS駆動

このUIを実現するにあたり、最もこだわったのが 「スクロール性能を一切犠牲にしないこと」 です。
メッセージが何千行と流れるチャット画面で、行ごとに mouseenter / mouseleave のJavaScriptイベントリスナーを登録すると、ガベージコレクション(GC)とメモリ消費が爆発して画面スクロールがカクついてしまいます。

そのため、動的な表示切り替えは 100% 純粋な CSS :hover だけで完結させました。

(1) DOM生成時の属性付与

メッセージ行(通常発言、ファイル共有カード、システムメッセージ)を描画する際、タイムスタンプ要素と title 属性を静的に1回だけ差し込みます。

// 時刻フォーマットユーティリティ
function formatFullDateTime(ts) {
    const d = new Date(ts);
    const pad = (n) => String(n).padStart(2, '0');
    return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

function formatTime(ts) {
    const d = new Date(ts);
    const pad = (n) => String(n).padStart(2, '0');
    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

// メッセージ描画時
renderChatMessage(item) {
    const row = document.createElement('div');
    row.className = 'p2p-msg-row';
    
    // ネイティブツールチップ用に完全日時を設定
    row.setAttribute('title', formatFullDateTime(item.timestamp));

    // ... 発言者・本文DOMの構築 ...

    // ★右端にホバー時専用のタイムスタンプバブルを配置
    const timeSpan = document.createElement('span');
    timeSpan.className = 'p2p-xchat-hover-time';
    timeSpan.textContent = formatTime(item.timestamp);
    row.appendChild(timeSpan);

    logContainer.appendChild(row);
}

(2) GPU合成レイヤーによる滑らかなアニメーション

/* メッセージ行の基準スタイル(相対配置) */
.p2p-msg-row {
    position: relative;
    padding: 3px 8px;
    line-height: 1.5;
    transition: background-color 0.1s ease;
}

/* ホバー時に行全体をほんのりハイライト */
.p2p-msg-row:hover {
    background-color: rgba(0, 0, 0, 0.03);
}
.dark-theme .p2p-msg-row:hover {
    background-color: rgba(255, 255, 255, 0.04);
}

/* ★ホバータイムスタンプバブル */
.p2p-xchat-hover-time {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%) scale(0.95);
    opacity: 0;
    pointer-events: none; /* マウスイベントを透過させてクリックの邪魔をしない */
    font-family: 'Consolas', 'Courier New', monospace;
    font-size: 11px;
    padding: 2px 6px;
    border-radius: 4px;
    background: rgba(240, 243, 246, 0.95);
    color: #57606a;
    border: 1px solid #d0d7de;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
    transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1),
                transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
    z-index: 5;
    white-space: nowrap;
}

/* ダークテーマ対応 */
.dark-theme .p2p-xchat-hover-time {
    background: rgba(33, 38, 45, 0.95);
    color: #8b949e;
    border-color: #30363d;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}

/* ★マウスが乗った瞬間にふわっと出現 */
.p2p-msg-row:hover .p2p-xchat-hover-time {
    opacity: 1;
    transform: translateY(-50%) scale(1);
}

[!TIP]
pointer-events: none; を指定しておくことで、ポップアップしたタイムスタンプの上にカーソルが重なっても、本文のテキスト選択やURLクリックが妨げられないように配慮しています。


4. 全メッセージタイプへの網羅適用

通常の発言メッセージだけでなく、チャットログに流れるすべての要素へ統一されたUXを適用しました。

  1. 通常テキスト発言 : 右端に HH:mm:ss が出現。
  2. P2Pファイル共有カード : ファイルカードにマウスを乗せると、ファイルが共有された正確な時刻がポップアップ。
  3. システムメッセージ : 入退室アナウンスやホスト移譲通知(* Alice が入室しました)でも同様に時刻を確認可能。

5. 実機検証と操作感の評価

自動テストおよび実機ブラウザでの操作検証を行いました。

  • 視覚的ノイズの劇的な削減 :
    • 通常時の画面は美しい白銀(または漆黒)のテキストログのみで、数字による圧迫感が完全に消失。
  • 60fpsのスムーズスクロール :
    • CSSトランジションのみで完結しているため、何千行のログを一気に高速スクロールしてもFPSの低下は一切観測されず、極めて軽快な操作感を維持。
  • 直感的な時刻確認 :
    • 「あの発言いつだっけ?」と思った瞬間にスッとマウスを乗せるだけで、ミリ秒の引っかかりもなく時刻が浮かび上がる心地よい体験を実現しました。

6. まとめと次回予告

「普段は見せず、求められた瞬間にだけスマートに応える」――。
CSSの力を最大限に活かしたホバータイムスタンプにより、XChatの機能美と現代的なインタラクションを高次元で融合させることができました。

しかし、UIの改善に一息ついたのも束の間、次のリリース直前にまたしてもトラブルが発生します。

JavaScriptファイルを軽量化・最適化する過程で、 たった1文字の構文ミス(Syntax Error)が混入 。
その結果、掲示板全体のスクリプトのパースが中断し、P2Pチャットの起動ボタンはおろか、掲示板の一部のUIまで巻き添えを食らって沈黙してしまったのです。

素のJavaScript運用におけるデバッグの落とし穴と、カプセル化(IIFE)による防壁構築の記録。

次回は、 【第037回】1文字の構文ミスで全滅!?P2Pチャットスクリプトのエラー解消とチャット起動ボタンの再配備 をお届けします。お楽しみに!

本番サービス : https://2ch.biz/

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?