Skip to content
Draft
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
124 changes: 124 additions & 0 deletions nt-fe/components/user-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";

interface UserAvatarProps {
accountId: string;
className?: string;
}

// Palette of [colorA, colorB] pairs — pastel two-shade pairs per hue
const COLOR_PAIRS: [string, string][] = [
["#A8C4E0", "#C8DCF0"], // soft blue
["#A8D5B5", "#C8EAD4"], // soft green
["#C4A8D5", "#DCC8EA"], // soft purple
["#F0C8A0", "#F5DEC0"], // soft orange
["#F0A8A8", "#F5C8C8"], // soft red
["#A8D5D5", "#C8EAEA"], // soft teal
["#E0D0A8", "#EDE0C0"], // soft gold
["#D5A8C4", "#EAC8DC"], // soft pink
];

/** Deterministic 32-bit hash from a string */
function hash(str: string): number {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h;
}

/** Simple xorshift32 RNG — same algorithm as the reference code */
function createRandom(seed: number) {
let s = seed || 1;
return () => {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
s = s >>> 0;
return s / 0xffffffff;
};
}

function randomBool(rng: () => number) {
return rng() > 0.5;
}

const GRID = 4; // 4×4 cells, matches the reference

/**
* Truchet-tile avatar fallback for NEAR accounts without a profile image.
* Each accountId deterministically maps to a unique colour pair and seed,
* producing a geometric pattern identical to the Scale Explorer generator.
*/
export function UserAvatar({ accountId, className }: UserAvatarProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);

const h = hash(accountId);
const [colorA, colorB] = COLOR_PAIRS[h % COLOR_PAIRS.length];
const seed = h;

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;

const size = canvas.width;
const cellSize = size / GRID;
const rng = createRandom(seed);

for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
const x = col * cellSize;
const y = row * cellSize;
const isBackslash = randomBool(rng);
const swap = randomBool(rng);
const c1 = swap ? colorB : colorA;
const c2 = swap ? colorA : colorB;

// First triangle
ctx.fillStyle = c1;
ctx.beginPath();
if (isBackslash) {
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
ctx.lineTo(x, y + cellSize);
} else {
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
ctx.lineTo(x + cellSize, y + cellSize);
}
ctx.closePath();
ctx.fill();

// Second triangle
ctx.fillStyle = c2;
ctx.beginPath();
if (isBackslash) {
ctx.moveTo(x + cellSize, y);
ctx.lineTo(x + cellSize, y + cellSize);
ctx.lineTo(x, y + cellSize);
} else {
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y + cellSize);
ctx.lineTo(x, y + cellSize);
}
ctx.closePath();
ctx.fill();
}
}
}, [seed, colorA, colorB]);

return (
<canvas
ref={canvasRef}
width={128}
height={128}
aria-label={accountId}
className={cn("rounded-full shrink-0 select-none", className)}
/>
);
}
23 changes: 16 additions & 7 deletions nt-fe/components/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Separator } from "./ui/separator";
import { Skeleton } from "./ui/skeleton";
import { CopyButton } from "./copy-button";
import { Address } from "./address";
import { UserAvatar } from "./user-avatar";

interface UserProps {
accountId: string;
Expand Down Expand Up @@ -106,6 +107,20 @@ export function User({

const image = `https://i.near.social/magic/large/https://near.social/magic/img/account/${accountId}`;

const avatar = (avatarClassName: string) => (
<div className="rounded-full flex bg-muted border border-border">
{profile?.image ? (
<img
src={image}
alt="User Logo"
className={cn("rounded-full shrink-0", avatarClassName)}
/>
) : (
<UserAvatar accountId={accountId} className={avatarClassName} />
)}
</div>
);

const name = profile?.name ? (
<span className="font-medium truncate max-w-full">{profile.name}</span>
) : (
Expand All @@ -117,13 +132,7 @@ export function User({

const content = (
<>
<div className="rounded-full flex bg-muted border border-border">
<img
src={image}
alt="User Logo"
className={cn("rounded-full shrink-0", sizeClasses[size])}
/>
</div>
{avatar(sizeClasses[size])}
{!iconOnly && (
<div className="flex flex-col items-start min-w-0">
{withName && name}
Expand Down
Loading