Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 4d14770

Browse files
committed
Icons per username
1 parent 583d46a commit 4d14770

5 files changed

Lines changed: 251 additions & 3 deletions

File tree

src/lib/avatar/generate.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Deterministic identicon generator.
3+
*
4+
* Given a seed string, produces a small symmetric pixel grid and an accent
5+
* colour. Pure function — same seed always yields the same result, so other
6+
* clients can render the same avatar without any storage.
7+
*
8+
* For user avatars, prefer `userAvatarSeed(username)` which namespaces the
9+
* seed with the OBP host so the same username on a different OBP instance
10+
* renders differently.
11+
*/
12+
13+
import { env } from '$env/dynamic/public';
14+
15+
/**
16+
* Build a deterministic per-user avatar seed: `${OBP_BASE_URL}|${username}`.
17+
*
18+
* Namespacing by host means the same username on different OBP instances
19+
* gets visually distinct avatars, while still being reproducible by anyone
20+
* who knows the host and the username.
21+
*/
22+
export function userAvatarSeed(username: string): string {
23+
const host = env.PUBLIC_OBP_BASE_URL ?? '';
24+
return `${host}|${username}`;
25+
}
26+
27+
/** FNV-1a 32-bit hash. */
28+
function hashSeed(seed: string): number {
29+
let h = 2166136261 >>> 0;
30+
for (let i = 0; i < seed.length; i++) {
31+
h ^= seed.charCodeAt(i);
32+
h = Math.imul(h, 16777619);
33+
}
34+
return h >>> 0;
35+
}
36+
37+
/** mulberry32 PRNG — deterministic from a 32-bit integer seed. */
38+
function mulberry32(seed: number): () => number {
39+
let s = seed >>> 0;
40+
return function () {
41+
s = (s + 0x6d2b79f5) | 0;
42+
let t = s;
43+
t = Math.imul(t ^ (t >>> 15), t | 1);
44+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
45+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
46+
};
47+
}
48+
49+
export interface Identicon {
50+
/** 2D boolean grid: rows × cols. */
51+
grid: boolean[][];
52+
/** Accent colour as an `hsl(...)` string. */
53+
color: string;
54+
/** Background colour as an `hsl(...)` string. */
55+
background: string;
56+
}
57+
58+
/**
59+
* Generate a deterministic identicon from a seed.
60+
* @param seed The string to derive the avatar from (e.g. user_id).
61+
* @param gridSize Width/height of the grid in cells. Default 5.
62+
*/
63+
export function generateIdenticon(seed: string, gridSize = 5): Identicon {
64+
const rng = mulberry32(hashSeed(seed));
65+
const halfWidth = Math.ceil(gridSize / 2);
66+
67+
// Burn a few values to decorrelate colour from grid pattern
68+
const hue = Math.floor(rng() * 360);
69+
const bgHue = (hue + 180) % 360;
70+
71+
const grid: boolean[][] = [];
72+
for (let row = 0; row < gridSize; row++) {
73+
const r: boolean[] = new Array(gridSize);
74+
for (let col = 0; col < halfWidth; col++) {
75+
r[col] = rng() < 0.5;
76+
}
77+
for (let col = halfWidth; col < gridSize; col++) {
78+
r[col] = r[gridSize - 1 - col];
79+
}
80+
grid.push(r);
81+
}
82+
83+
return {
84+
grid,
85+
color: `hsl(${hue}, 65%, 50%)`,
86+
background: `hsl(${bgHue}, 30%, 92%)`
87+
};
88+
}

src/lib/chat/sender.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* A chat message can be authored by a user, a consumer (app), or both.
3+
* Prefer the human username when present; otherwise show the consumer name.
4+
*/
5+
export interface ChatMessageSenderFields {
6+
sender_username: string;
7+
sender_consumer_name: string;
8+
}
9+
10+
/** Display name of the message author — username if present, otherwise consumer name. */
11+
export function messageSenderName(message: ChatMessageSenderFields): string {
12+
return message.sender_username ? message.sender_username : message.sender_consumer_name;
13+
}

src/lib/components/Avatar.svelte

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<script lang="ts">
2+
import { generateIdenticon } from '$lib/avatar/generate';
3+
4+
interface Props {
5+
seed: string;
6+
size?: number;
7+
gridSize?: number;
8+
title?: string;
9+
}
10+
11+
let { seed, size = 40, gridSize = 5, title }: Props = $props();
12+
13+
const identicon = $derived(generateIdenticon(seed, gridSize));
14+
const cellSize = $derived(size / gridSize);
15+
const radius = $derived(size * 0.15);
16+
</script>
17+
18+
<svg
19+
width={size}
20+
height={size}
21+
viewBox="0 0 {size} {size}"
22+
role="img"
23+
aria-label={title ?? `Avatar for ${seed}`}
24+
data-testid="avatar"
25+
data-seed={seed}
26+
>
27+
<rect width={size} height={size} rx={radius} ry={radius} fill={identicon.background} />
28+
{#each identicon.grid as row, y}
29+
{#each row as filled, x}
30+
{#if filled}
31+
<rect
32+
x={x * cellSize}
33+
y={y * cellSize}
34+
width={cellSize}
35+
height={cellSize}
36+
fill={identicon.color}
37+
/>
38+
{/if}
39+
{/each}
40+
{/each}
41+
</svg>

src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { ArrowLeft, Send, Users, Settings, Pencil, Check, X, SmilePlus, Reply, Bold, Italic, Code, Link, List, SquareCode } from '@lucide/svelte';
44
import { unreadCount } from '$lib/stores/unreadCount.svelte';
55
import { browser } from '$app/environment';
6+
import Avatar from '$lib/components/Avatar.svelte';
7+
import { userAvatarSeed } from '$lib/avatar/generate';
8+
import { messageSenderName } from '$lib/chat/sender';
69
710
// Both renderMarkdown (Prism) and DOMPurify require browser globals — lazy-load them
811
let renderMarkdown: ((content: string) => string) | null = $state(null);
@@ -713,10 +716,18 @@
713716
{#each messages as message (message.chat_message_id)}
714717
{@const isOwn = message.sender_user_id === data.currentUserId}
715718
{@const msgReactions = groupedReactions(message.chat_message_id)}
719+
{@const senderName = messageSenderName(message)}
716720
<div
717721
class="group flex gap-2"
718722
data-testid="message-{message.chat_message_id}"
719723
>
724+
<div class="shrink-0 mt-1">
725+
<Avatar
726+
seed={userAvatarSeed(senderName)}
727+
size={32}
728+
title="Avatar for {senderName}"
729+
/>
730+
</div>
720731
<div class="relative flex-1 min-w-0">
721732
<!-- Emoji picker popup -->
722733
{#if emojiPickerMessageId === message.chat_message_id}
@@ -738,7 +749,7 @@
738749
{/if}
739750
<div class="rounded-lg px-4 py-2 bg-surface-100-800 text-surface-900-50 border-l-2 {isOwn ? 'border-primary-500' : 'border-transparent'}">
740751
<p class="mb-1 text-xs font-semibold opacity-70" data-testid="message-sender">
741-
{message.sender_username || message.sender_user_id}
752+
{senderName}
742753
</p>
743754
{#if message.reply_to_message_id}
744755
{@const parent = messages.find(m => m.chat_message_id === message.reply_to_message_id)}
@@ -749,7 +760,7 @@
749760
data-testid="reply-ref-{message.chat_message_id}"
750761
>
751762
{#if parent}
752-
{parent.sender_username || parent.sender_user_id}: {parent.content?.slice(0, 80)}{parent.content?.length > 80 ? '...' : ''}
763+
{messageSenderName(parent)}: {parent.content?.slice(0, 80)}{parent.content?.length > 80 ? '...' : ''}
753764
{:else}
754765
Reply to a message
755766
{/if}
@@ -864,7 +875,7 @@
864875
{#if replyingTo}
865876
<div class="flex items-center gap-2 mb-2 rounded-lg border border-surface-300-600 bg-surface-100-800 px-3 py-2" data-testid="reply-preview">
866877
<div class="flex-1 min-w-0">
867-
<p class="text-xs text-surface-500">Replying to <span class="font-semibold">{replyingTo.sender_username || replyingTo.sender_user_id}</span></p>
878+
<p class="text-xs text-surface-500">Replying to <span class="font-semibold">{messageSenderName(replyingTo)}</span></p>
868879
<p class="text-sm text-surface-700-300 truncate">{replyingTo.content}</p>
869880
</div>
870881
<button
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<script lang="ts">
2+
import Avatar from '$lib/components/Avatar.svelte';
3+
import { userAvatarSeed } from '$lib/avatar/generate';
4+
import { env } from '$env/dynamic/public';
5+
6+
const usernames = [
7+
'simonredfern2025',
8+
'simonredferncopenhagen',
9+
'robert.x.0.gh',
10+
'alice',
11+
'bob',
12+
'charlie',
13+
'dana',
14+
'eve',
15+
'frank',
16+
'grace',
17+
'heidi',
18+
'ivan',
19+
'judy',
20+
'kim',
21+
'leo',
22+
'mallory'
23+
];
24+
25+
const host = env.PUBLIC_OBP_BASE_URL ?? '(unset)';
26+
27+
const sizes = [24, 40, 64, 96];
28+
let selectedSize = $state(64);
29+
let selectedGrid = $state(5);
30+
</script>
31+
32+
<svelte:head>
33+
<title>Avatar preview</title>
34+
</svelte:head>
35+
36+
<div class="space-y-8 p-6">
37+
<header>
38+
<h1 class="text-2xl font-bold">Avatar preview</h1>
39+
<p class="text-sm text-surface-600-400 mt-1">
40+
Deterministic pixel-art identicons. Seed = <code>OBP_BASE_URL|username</code>, so the
41+
same username on a different OBP instance produces a different avatar.
42+
</p>
43+
<p class="text-xs text-surface-500 mt-1">
44+
Host: <code>{host}</code>
45+
</p>
46+
</header>
47+
48+
<section class="flex flex-wrap items-center gap-6">
49+
<label class="flex items-center gap-2 text-sm">
50+
Size:
51+
<select bind:value={selectedSize} class="select rounded border border-surface-300-600 px-2 py-1">
52+
{#each sizes as s}
53+
<option value={s}>{s}px</option>
54+
{/each}
55+
</select>
56+
</label>
57+
<label class="flex items-center gap-2 text-sm">
58+
Grid:
59+
<select bind:value={selectedGrid} class="select rounded border border-surface-300-600 px-2 py-1">
60+
{#each [5, 6, 7, 8, 10] as g}
61+
<option value={g}>{g}×{g}</option>
62+
{/each}
63+
</select>
64+
</label>
65+
</section>
66+
67+
<section>
68+
<h2 class="mb-3 text-lg font-semibold">Sample usernames</h2>
69+
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
70+
{#each usernames as username (username)}
71+
<div class="flex items-center gap-3 rounded-lg border border-surface-300-600 bg-surface-50-900 p-3">
72+
<Avatar seed={userAvatarSeed(username)} size={selectedSize} gridSize={selectedGrid} title="Avatar for {username}" />
73+
<code class="truncate text-xs text-surface-600-400" title={username}>{username}</code>
74+
</div>
75+
{/each}
76+
</div>
77+
</section>
78+
79+
<section>
80+
<h2 class="mb-3 text-lg font-semibold">Inline use (chat-message style)</h2>
81+
<div class="space-y-3">
82+
{#each usernames.slice(0, 6) as username (username)}
83+
<div class="flex items-start gap-3 rounded-lg border border-surface-300-600 bg-surface-50-900 p-3">
84+
<Avatar seed={userAvatarSeed(username)} size={32} gridSize={selectedGrid} title="Avatar for {username}" />
85+
<div class="flex-1">
86+
<p class="text-sm font-semibold">{username}</p>
87+
<p class="text-sm text-surface-600-400">
88+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
89+
</p>
90+
</div>
91+
</div>
92+
{/each}
93+
</div>
94+
</section>
95+
</div>

0 commit comments

Comments
 (0)