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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@medialane/ui",
"version": "0.150.0",
"version": "0.153.0",
"description": "Shared UI components for Medialane apps",
"type": "module",
"sideEffects": false,
Expand Down
65 changes: 43 additions & 22 deletions src/components/nav-command-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import * as React from "react";
import { Command } from "cmdk";
import { Command, defaultFilter } from "cmdk";
import { useRouter } from "./router.js";
import { Search, X, ArrowRight } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
Expand Down Expand Up @@ -63,6 +63,13 @@ function Kbd({ children, className }: { children: React.ReactNode; className?: s
);
}

const RELEVANCE_FLOOR = 0.2;

export function relevanceFilter(value: string, search: string, keywords?: string[]): number {
const score = defaultFilter(value, search, keywords);
return score >= RELEVANCE_FLOOR ? score : 0;
}

function CommandRow({ item, primary, onSelect }: { item: NavCommand; primary: boolean; onSelect: () => void }) {
return (
<Command.Item
Expand Down Expand Up @@ -129,6 +136,7 @@ export function NavCommandMenu({
}: NavCommandMenuProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const searching = query.trim().length > 0;
const router = useRouter();
const inputRef = React.useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -201,7 +209,7 @@ export function NavCommandMenu({
)}
onClick={(e) => e.stopPropagation()}
>
<Command shouldFilter label="Medialane navigation" className="flex min-h-0 flex-1 flex-col">
<Command shouldFilter filter={relevanceFilter} label="Medialane navigation" className="flex min-h-0 flex-1 flex-col">

<div className="flex justify-center pt-2.5 sm:hidden" aria-hidden="true">
<span className="h-1 w-9 rounded-full bg-muted-foreground/30" />
Expand Down Expand Up @@ -236,26 +244,39 @@ export function NavCommandMenu({
No results found.
</Command.Empty>

{commands.map((group, i) => {
const primary = !group.heading;
return (
<React.Fragment key={group.heading ?? `__primary-${i}`}>
{i > 0 && (
<Command.Separator className="my-1.5 h-px bg-border/40" />
)}
<Command.Group heading={group.heading} className={GROUP_HEADING_CLASSES}>
{group.items.map((item) => (
<CommandRow
key={item.id}
item={item}
primary={primary}
onSelect={() => runCommand(item)}
/>
))}
</Command.Group>
</React.Fragment>
);
})}
{searching ? (
<Command.Group className={GROUP_HEADING_CLASSES}>
{commands.flatMap((group) => group.items).map((item) => (
<CommandRow
key={item.id}
item={item}
primary={false}
onSelect={() => runCommand(item)}
/>
))}
</Command.Group>
) : (
commands.map((group, i) => {
const primary = !group.heading;
return (
<React.Fragment key={group.heading ?? `__primary-${i}`}>
{i > 0 && (
<Command.Separator className="my-1.5 h-px bg-border/40" />
)}
<Command.Group heading={group.heading} className={GROUP_HEADING_CLASSES}>
{group.items.map((item) => (
<CommandRow
key={item.id}
item={item}
primary={primary}
onSelect={() => runCommand(item)}
/>
))}
</Command.Group>
</React.Fragment>
);
})
)}
</Command.List>

{accountSlot && (
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export { cn } from "./utils/cn.js";
export { formatDisplayPrice, parsePriceDisplay, isStableCurrency, formatUsd, formatUsdPrice, formatSmallDecimal } from "./utils/format.js";
export { shortenAddress } from "./utils/address.js";
export { profileIdentity, type ProfileIdentity, type ProfileIdentitySource } from "./utils/profile-identity.js";
export { ipfsToHttp, DEFAULT_GATEWAY as PINATA_PUBLIC_GATEWAY } from "./utils/ipfs.js";
export {
uploadFileToIpfs,
Expand Down Expand Up @@ -112,6 +113,7 @@ export {
} from "./components/coins-explorer.js";

export { timeAgo, timeUntil, isExpired } from "./utils/time.js";
export { toDisplayUrl, toDisplayUrlOrNull, toAbsoluteImageUrl, type DisplayUrlOptions } from "./utils/ipfs.js";
export { isSameAddress } from "./utils/same-address.js";
export { ACTIVITY_TYPE_CONFIG, TYPE_FILTERS } from "./data/activity.js";
export type { ActivityTypeConfig } from "./data/activity.js";
Expand Down Expand Up @@ -187,7 +189,7 @@ export { LAUNCHPAD_ROUTE_OVERRIDES } from "./components/launchpad-services.js";
export { LAUNCHPAD_SERVICE_DEFINITIONS, LAUNCHPAD_SERVICE_GROUPS } from "./data/launchpad-services.js";
export type { ServiceDefinition, ServiceStatus, ServiceGroup, ServiceGroupDefinition } from "./data/launchpad-services.js";

export { NavCommandMenu, useNavCommandMenu } from "./components/nav-command-menu.js";
export { NavCommandMenu, useNavCommandMenu, relevanceFilter } from "./components/nav-command-menu.js";
export type { NavCommand, NavCommandGroup, NavCommandMenuProps } from "./components/nav-command-menu.js";

export {
Expand Down
70 changes: 70 additions & 0 deletions src/utils/display-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { test, expect } from "bun:test";
import { toDisplayUrl, toDisplayUrlOrNull, toAbsoluteImageUrl } from "./ipfs.js";

const G = "https://gateway.pinata.cloud/ipfs/";
const CID = "bafybeif44ivsaive7an4l55lhvlap56qjjqprxkzvngpcd2b675dkdryga";

test("an ipfs reference resolves to the configured gateway", () => {
expect(toDisplayUrl(`ipfs://${CID}`)).toContain(`${G}${CID}`);
});

test("a different gateway is honoured", () => {
const out = toDisplayUrl(`ipfs://${CID}`, { gateway: "https://my.mypinata.cloud/ipfs/" });
expect(out).toContain("my.mypinata.cloud");
});

test("an external https image goes through the proxy rather than direct", () => {
expect(toDisplayUrl("https://example.com/a.png")).toBe(
"/api/img?url=" + encodeURIComponent("https://example.com/a.png"),
);
});

test("a data image URI is returned untouched", () => {
const d = "data:image/png;base64,AAAA";
expect(toDisplayUrl(d)).toBe(d);
});

test("nothing resolvable yields the placeholder, never an empty src", () => {
expect(toDisplayUrl(null)).toBe("/placeholder.svg");
expect(toDisplayUrl("")).toBe("/placeholder.svg");
expect(toDisplayUrl("not-a-url")).toBe("/placeholder.svg");
});

test("the optional form distinguishes absent from unresolvable", () => {
expect(toDisplayUrlOrNull(null)).toBeNull();
expect(toDisplayUrlOrNull("")).toBeNull();
expect(toDisplayUrlOrNull("not-a-url")).toBe("/placeholder.svg");
});

test("a local path is passed through, so app assets still render", () => {
expect(toDisplayUrlOrNull("/placeholder.svg")).toBe("/placeholder.svg");
expect(toDisplayUrlOrNull("/img/logo.png")).toBe("/img/logo.png");
});

test("a stored route path is not treated as an image reference", () => {
const dead = `/api/ipfs/${CID}`;
expect(toDisplayUrlOrNull(dead)).toBe(dead);
});

test("a crawler URL is absolute, never an app-relative route", () => {
const out = toAbsoluteImageUrl(`ipfs://${CID}`);
expect(out.startsWith("https://")).toBe(true);
});

test("an external image stays absolute rather than routing through the proxy", () => {
expect(toAbsoluteImageUrl("https://example.com/a.png")).toBe("https://example.com/a.png");
});

test("no image yields an empty string, not a broken URL", () => {
expect(toAbsoluteImageUrl(null)).toBe("");
expect(toAbsoluteImageUrl("")).toBe("");
});

test("an image data URI still renders", () => {
const d = "data:image/png;base64,AAAA";
expect(toDisplayUrlOrNull(d)).toBe(d);
});

test("a data URI that is not an image gets the placeholder, not an unrenderable src", () => {
expect(toDisplayUrlOrNull("data:text/html,<script>")).toBe("/placeholder.svg");
});
41 changes: 41 additions & 0 deletions src/utils/ipfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,44 @@ export function ipfsToHttp(
}
return uri;
}

export interface DisplayUrlOptions {
gateway?: string;
proxyPath?: string;
placeholder?: string;
}

export function toDisplayUrl(
uri: string | null | undefined,
opts: DisplayUrlOptions = {},
): string {
const gateway = opts.gateway ?? DEFAULT_GATEWAY;
const placeholder = opts.placeholder ?? "/placeholder.svg";
const proxyPath = opts.proxyPath ?? "/api/img";

if (!uri) return placeholder;
if (uri.startsWith("data:image/")) return uri;

const resolved = ipfsToHttp(uri, { gateway });
if (resolved.startsWith(gateway)) return resolved;

if (uri.startsWith("https://") || uri.startsWith("http://")) {
return `${proxyPath}?url=${encodeURIComponent(uri)}`;
}

return placeholder;
}

export function toDisplayUrlOrNull(
raw: string | null | undefined,
opts: DisplayUrlOptions = {},
): string | null {
if (!raw) return null;
if (raw.startsWith("/")) return raw;
return toDisplayUrl(raw, opts);
}

export function toAbsoluteImageUrl(uri: string | null | undefined): string {
if (!uri) return "";
return ipfsToHttp(uri) || "";
}
57 changes: 57 additions & 0 deletions src/utils/profile-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect } from "bun:test";
import { profileIdentity } from "./profile-identity.js";
import { shortenAddress } from "./address.js";

const ADDRESS = "0x057b4f2390e6239194aa04608133e7d6652d31de318d3b7dfcd63db440579fc1";

test("a claimed username is the identity", () => {
const { identity, name, hasUsername } = profileIdentity({
username: "devdesign",
name: "Alkera Dev/Design",
walletAddress: ADDRESS,
});
expect(identity).toBe("@devdesign");
expect(name).toBe("Alkera Dev/Design");
expect(hasUsername).toBe(true);
});

test("without a username the address identifies the account, never the name", () => {
const { identity, name, hasUsername } = profileIdentity({
name: "Kimia shayesteh",
walletAddress: ADDRESS,
});
expect(identity).toBe(shorten(ADDRESS));
expect(name).toBe("Kimia shayesteh");
expect(hasUsername).toBe(false);
});

test("a name claiming to be the platform does not become the identity", () => {
const { identity, name } = profileIdentity({
name: "medialane",
walletAddress: ADDRESS,
});
expect(identity).toBe(shorten(ADDRESS));
expect(identity).not.toContain("medialane");
expect(name).toBe("medialane");
});

test("a name matching someone else's handle does not borrow the @ prefix", () => {
const { identity } = profileIdentity({ name: "@devdesign", walletAddress: ADDRESS });
expect(identity).toBe(shorten(ADDRESS));
});

test("blank and whitespace values are treated as absent", () => {
expect(profileIdentity({ username: " ", name: "", walletAddress: ADDRESS })).toEqual({
identity: shorten(ADDRESS),
name: null,
hasUsername: false,
});
});

test("an account with nothing at all still renders something", () => {
expect(profileIdentity({}).identity).toBe("Unknown account");
});

function shorten(a: string) {
return shortenAddress(a);
}
25 changes: 25 additions & 0 deletions src/utils/profile-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { shortenAddress } from "./address.js";

export interface ProfileIdentitySource {
username?: string | null;
name?: string | null;
walletAddress?: string | null;
}

export interface ProfileIdentity {
identity: string;
name: string | null;
hasUsername: boolean;
}

export function profileIdentity(source: ProfileIdentitySource): ProfileIdentity {
const username = source.username?.trim() || null;
const name = source.name?.trim() || null;
const address = source.walletAddress?.trim() || null;

return {
identity: username ? `@${username}` : address ? shortenAddress(address) : "Unknown account",
name,
hasUsername: Boolean(username),
};
}