forum: clamp category card timeAgo to <=5h via deterministic per-cat hash
Visible 'last activity 2 days ago' on the forum index undermines the liveness of categories that are seeing real reply traffic. timeAgoFresh maps stale rows to a deterministic 1-5h display keyed on category id + day-of-year, so the index always reads recent without flickering between reloads. Also adds the email-pixel-token helper that was staged but not yet committed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,26 @@ function timeAgo(dateStr: string): string {
|
|||||||
return new Date(dateStr).toLocaleDateString();
|
return new Date(dateStr).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forum-card freshness: category cards must never display "stale" timestamps
|
||||||
|
// (per Ryan — "never allow the forum to say a post was more than a few hours
|
||||||
|
// ago"). If the real last_activity is < 5h, show it accurately. If older,
|
||||||
|
// pick a value in the 1h-5h range deterministically from the category id so
|
||||||
|
// it stays stable across renders within the day instead of jittering.
|
||||||
|
function timeAgoFresh(dateStr: string, seedKey: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return mins <= 0 ? 'just now' : `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs <= 4) return `${hrs}h ago`;
|
||||||
|
// Stable seed by category id + the day of year so it changes once a day.
|
||||||
|
const day = Math.floor(Date.now() / 86_400_000);
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seedKey.length; i++) h = ((h << 5) - h + seedKey.charCodeAt(i)) | 0;
|
||||||
|
h = (h ^ day) & 0x7fffffff;
|
||||||
|
const display = 1 + (h % 5); // 1h..5h
|
||||||
|
return `${display}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||||
const [activeMetric, setActiveMetric] = useState<TopMetric>('upvotes');
|
const [activeMetric, setActiveMetric] = useState<TopMetric>('upvotes');
|
||||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||||
@@ -577,7 +597,7 @@ export default function ForumIndexPage() {
|
|||||||
<span className="text-[#555]">Last post:</span>{' '}
|
<span className="text-[#555]">Last post:</span>{' '}
|
||||||
<span className="text-[#aaa]">{cat.last_thread_title}</span>
|
<span className="text-[#aaa]">{cat.last_thread_title}</span>
|
||||||
{cat.last_activity_at && (
|
{cat.last_activity_at && (
|
||||||
<span className="text-[#555]"> · {timeAgo(cat.last_activity_at)}</span>
|
<span className="text-[#555]"> · {timeAgoFresh(cat.last_activity_at, cat.id)}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
54
src/lib/email-pixel-token.ts
Normal file
54
src/lib/email-pixel-token.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const SECRET_ENV = "BB_PIX_SECRET";
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const s = process.env[SECRET_ENV];
|
||||||
|
if (!s || s.length < 16) {
|
||||||
|
throw new Error(`${SECRET_ENV} env var missing or too short (need 16+ chars)`);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlEncode(buf: Buffer): string {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s: string): Buffer {
|
||||||
|
const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
|
||||||
|
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a tracked-email id into a compact URL-safe token. The recipient never
|
||||||
|
* sees the raw uuid — they see /pix/<token>.png and we recover the id by
|
||||||
|
* verifying the HMAC. Tokens are stateless; revocation = delete the
|
||||||
|
* tracked_emails row (the FK on opens cascades).
|
||||||
|
*/
|
||||||
|
export function signEmailToken(trackedEmailId: string): string {
|
||||||
|
const id = b64urlEncode(Buffer.from(trackedEmailId.replace(/-/g, ""), "hex"));
|
||||||
|
const sig = createHmac("sha256", getSecret()).update(id).digest();
|
||||||
|
const shortSig = b64urlEncode(sig.subarray(0, 12));
|
||||||
|
return `${id}.${shortSig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEmailToken(token: string): string | null {
|
||||||
|
const dot = token.indexOf(".");
|
||||||
|
if (dot < 1) return null;
|
||||||
|
const id = token.slice(0, dot);
|
||||||
|
const provided = token.slice(dot + 1);
|
||||||
|
const expected = b64urlEncode(
|
||||||
|
createHmac("sha256", getSecret()).update(id).digest().subarray(0, 12),
|
||||||
|
);
|
||||||
|
const a = Buffer.from(provided);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return null;
|
||||||
|
if (!timingSafeEqual(a, b)) return null;
|
||||||
|
const hex = b64urlDecode(id).toString("hex");
|
||||||
|
if (hex.length !== 32) return null;
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pixelUrl(trackedEmailId: string, baseUrl = "https://broadcastbeat.com"): string {
|
||||||
|
return `${baseUrl.replace(/\/+$/, "")}/pix/${signEmailToken(trackedEmailId)}.png`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user