Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/desktop/src/components/ChatFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import {
Component,
For,
Show,
createEffect,
createMemo,
createSignal,
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
prepareMessage,
type PreparedMessage,
} from "../lib/messageLayout";
import { normalizeUserColor } from "../lib/messageStyle";
import type { MessagePiece } from "../lib/emoteSpans";

const OVERSCAN = 6;
Expand Down Expand Up @@ -306,6 +308,16 @@ const ChatFeed: Component = () => {
"overflow-wrap": "break-word",
}}
>
<Show when={item.prepared.timestamp}>
<span
style={{
color: "#6e6e72",
"white-space": "pre",
}}
>
{item.prepared.timestamp}
</span>
</Show>
<For each={item.prepared.badges}>
{(b) => (
<img
Expand All @@ -332,7 +344,7 @@ const ChatFeed: Component = () => {
</For>
<span
style={{
color: item.msg.color || "#9147ff",
color: normalizeUserColor(item.msg.color),
"font-weight": 700,
// Keep DOM in lockstep with Pretext's `break: "never"`
// on the username segment so heights stay accurate even
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/lib/messageLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { measureNaturalWidth, prepareWithSegments } from "@chenglou/pretext";
import type { ChatMessage } from "../stores/chatStore";
import type { ResolvedBadge } from "../stores/badgeStore";
import { splitMessage, type MessagePiece } from "./emoteSpans";
import { formatTimestamp } from "./messageStyle";

// Named families only. `system-ui` is unsafe for pretext accuracy on macOS.
// Exported so `ChatFeed.tsx` applies the exact same stack that Pretext
Expand All @@ -23,6 +24,9 @@ export const MESSAGE_FONT_SIZE_PX = 13;
const USERNAME_FONT = `700 ${MESSAGE_FONT_SIZE_PX}px ${MESSAGE_FONT_FAMILY}`;
const SEPARATOR_FONT = `400 ${MESSAGE_FONT_SIZE_PX}px ${MESSAGE_FONT_FAMILY}`;
const TEXT_FONT = `400 ${MESSAGE_FONT_SIZE_PX}px ${MESSAGE_FONT_FAMILY}`;
// Timestamps render in the same family/size as body text but in the
// dim-grey color the DOM applies; weight stays 400.
const TIMESTAMP_FONT = TEXT_FONT;

export const MESSAGE_LINE_HEIGHT = 20;
export const MESSAGE_PADDING_Y = 4;
Expand Down Expand Up @@ -62,6 +66,7 @@ export interface PreparedMessage {
prepared: PreparedRichInline;
pieces: MessagePiece[];
badges: BadgeRender[];
timestamp: string;
}

export function prepareMessage(
Expand All @@ -80,6 +85,22 @@ export function prepareMessage(

const items: RichInlineItem[] = [];
const placeholder = placeholderWidth(TEXT_FONT);

// Timestamp prefix: a single non-breaking text run so the username
// never wraps onto a line by itself when the timestamp pushes the
// first badge down. The trailing space keeps separation from badges
// without needing a separate item, and we expose the exact same
// string to the DOM so Pretext's measurement matches what renders.
const formatted = formatTimestamp(msg.timestamp);
const timestamp = formatted ? `${formatted} ` : "";
if (timestamp) {
items.push({
text: timestamp,
font: TIMESTAMP_FONT,
break: "never",
});
}

const badgeExtra = Math.max(0, BADGE_SIZE_PX + BADGE_GAP_PX - placeholder);
for (let i = 0; i < badges.length; i++) {
items.push({
Expand Down Expand Up @@ -107,7 +128,7 @@ export function prepareMessage(
}
}

return { prepared: prepareRichInline(items), pieces, badges };
return { prepared: prepareRichInline(items), pieces, badges, timestamp };
}

export function measureMessageHeight(
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/lib/messageStyle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { formatTimestamp, normalizeUserColor } from "./messageStyle";

describe("normalizeUserColor", () => {
it("returns the fallback for null/empty/invalid", () => {
expect(normalizeUserColor(null)).toBe("#9147ff");
expect(normalizeUserColor("")).toBe("#9147ff");
expect(normalizeUserColor("not-a-color")).toBe("#9147ff");
expect(normalizeUserColor("#xyz")).toBe("#9147ff");
});

it("passes through readable colors unchanged", () => {
expect(normalizeUserColor("#ff7f50")).toBe("#ff7f50");
expect(normalizeUserColor("#ffffff")).toBe("#ffffff");
});

it("expands 3-digit hex", () => {
expect(normalizeUserColor("#fc8")).toBe("#ffcc88");
});

it("lifts pitch black to a visible grey", () => {
const out = normalizeUserColor("#000000");
expect(out).not.toBe("#000000");
// Shouldn't blow out to white either; just enough to read.
expect(out).not.toBe("#ffffff");
});

it("lifts deep blue to a readable blue", () => {
const out = normalizeUserColor("#0000ff");
// Channel zero gets lifted, so blue stays dominant but red/green grow.
expect(out).toMatch(/^#[0-9a-f]{6}$/);
expect(out).not.toBe("#0000ff");
});
});

describe("formatTimestamp", () => {
it("zero-pads hours and minutes", () => {
// 2026-01-01T03:07:00 local time
const d = new Date(2026, 0, 1, 3, 7, 0).getTime();
expect(formatTimestamp(d)).toBe("03:07");
});

it("returns empty string for NaN", () => {
expect(formatTimestamp(Number.NaN)).toBe("");
});
});
89 changes: 89 additions & 0 deletions apps/desktop/src/lib/messageStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Username color normalization for chat. Twitch (and YouTube/Kick) lets
// users pick any hex color; many of those are unreadable on the app's
// dark background (e.g. #0000FF, #2E0000). We boost luminance until the
// color clears a minimum readable threshold against #0e0e10, matching
// the behavior the official Twitch web client uses for "Adjust Colors".

const FALLBACK = "#9147ff";

// WCAG-style relative luminance threshold. 0.20 keeps deep colors moody
// but readable; anything lower (#000080 etc.) gets lifted toward white.
const MIN_LUMINANCE = 0.2;

interface Rgb {
r: number;
g: number;
b: number;
}

function parseHex(input: string): Rgb | null {
let s = input.trim();
if (s.startsWith("#")) s = s.slice(1);
if (s.length === 3) {
s = s
.split("")
.map((c) => c + c)
.join("");
}
if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) return null;
return {
r: parseInt(s.slice(0, 2), 16),
g: parseInt(s.slice(2, 4), 16),
b: parseInt(s.slice(4, 6), 16),
};
}

function srgbToLinear(c: number): number {
const v = c / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}

function relativeLuminance({ r, g, b }: Rgb): number {
return (
0.2126 * srgbToLinear(r) +
0.7152 * srgbToLinear(g) +
0.0722 * srgbToLinear(b)
);
}

function toHex({ r, g, b }: Rgb): string {
const h = (n: number) => Math.round(n).toString(16).padStart(2, "0");
return `#${h(r)}${h(g)}${h(b)}`;
}

// Iteratively lift each channel toward white until the color clears the
// minimum luminance. A pure ratio scale would still produce black for
// black inputs, so we add a constant floor before scaling.
function liftToReadable(rgb: Rgb): Rgb {
let { r, g, b } = rgb;
// Seed pitch-black with a small grey so the loop has something to scale.
if (r + g + b < 24) {
r = g = b = 24;
}
for (let i = 0; i < 16; i++) {
if (relativeLuminance({ r, g, b }) >= MIN_LUMINANCE) break;
r = Math.min(255, r + (255 - r) * 0.2 + 8);
g = Math.min(255, g + (255 - g) * 0.2 + 8);
b = Math.min(255, b + (255 - b) * 0.2 + 8);
}
return { r, g, b };
}

export function normalizeUserColor(color: string | null | undefined): string {
if (!color) return FALLBACK;
const rgb = parseHex(color);
if (!rgb) return FALLBACK;
if (relativeLuminance(rgb) >= MIN_LUMINANCE) return toHex(rgb);
return toHex(liftToReadable(rgb));
}

// Format a Unix-millis timestamp as a short 24-hour HH:MM string in the
// user's local timezone. Stable across renders for the same input so the
// virtual scroller doesn't churn.
export function formatTimestamp(unixMillis: number): string {
const d = new Date(unixMillis);
if (Number.isNaN(d.getTime())) return "";
const hh = d.getHours().toString().padStart(2, "0");
const mm = d.getMinutes().toString().padStart(2, "0");
return `${hh}:${mm}`;
}
Loading