diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts
index e9216ad..fb8679b 100644
--- a/src/api/schema.d.ts
+++ b/src/api/schema.d.ts
@@ -986,6 +986,10 @@ export interface components {
updated_at: string;
/** @description User biography with markdown, HTML, and plain text renderings */
bio?: components["schemas"]["MarkdownContent"] | null;
+ /** @description Denotes plan or other distinction */
+ badge: components["schemas"]["UserBadge"] | null;
+ /** @description User's subscription tier */
+ tier: components["schemas"]["UserTier"];
counts: components["schemas"]["UserCounts"];
/** @description HATEOAS links for navigation */
_links: components["schemas"]["Links"];
@@ -1043,6 +1047,15 @@ export interface components {
*/
users: number;
};
+ /**
+ * @description Denotes plan or other distinction:
+ * - `staff`: Are.na staff member
+ * - `investor`: Investor
+ * - `supporter`: Supporter subscriber
+ * - `premium`: Premium subscriber
+ * @enum {string}
+ */
+ UserBadge: "staff" | "investor" | "supporter" | "premium";
/**
* @description User subscription tier:
* - `guest`: Unauthenticated user
diff --git a/src/api/types.ts b/src/api/types.ts
index b760af9..7855fbf 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -34,6 +34,7 @@ export type ConnectionSort = Schemas["ConnectionSort"];
export type ChannelContentSort = Schemas["ChannelContentSort"];
export type ContentSort = Schemas["ContentSort"];
export type ConnectionFilter = Schemas["ConnectionFilter"];
+export type UserTier = Schemas["UserTier"];
export type PresignedFile = Schemas["PresignedFile"];
export type PaginationMeta = Schemas["PaginationMeta"];
diff --git a/src/commands/search.tsx b/src/commands/search.tsx
index 10ad1a6..2df3a2a 100644
--- a/src/commands/search.tsx
+++ b/src/commands/search.tsx
@@ -13,6 +13,9 @@ import { BlockItem } from "../components/BlockItem";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
import { plural } from "../lib/format";
+import { openUrl } from "../lib/open";
+
+const BILLING_URL = "https://www.are.na/billing";
interface Props {
query: string;
@@ -69,11 +72,23 @@ export function SearchCommand({
if (loading) return ;
if (error) {
+ const isPremiumError = error.includes("Forbidden");
+
+ if (isPremiumError) {
+ openUrl(BILLING_URL);
+ }
+
return (
✕ {error}
- {error.includes("Forbidden") && (
- Search requires an Are.na Premium subscription
+ {isPremiumError && (
+ <>
+
+ {" "}
+ Search requires an Are.na Premium subscription
+
+ Opening {BILLING_URL}
+ >
)}
);
diff --git a/src/commands/whoami.tsx b/src/commands/whoami.tsx
index 664b66f..179c4e1 100644
--- a/src/commands/whoami.tsx
+++ b/src/commands/whoami.tsx
@@ -2,7 +2,7 @@ import { Box, Text } from "ink";
import { arenaApiBaseUrl, client, getData } from "../api/client";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
-import { plural } from "../lib/format";
+import { formatTier, plural } from "../lib/format";
export function WhoamiCommand() {
const { data, error, loading } = useCommand(() =>
@@ -15,16 +15,30 @@ export function WhoamiCommand() {
const stats = [
plural(data.counts.channels, "channel"),
-
`${data.counts.following.toLocaleString()} following`,
plural(data.counts.followers, "follower"),
]
.filter(Boolean)
.join(" · ");
+ const tierLabel = formatTier(data.tier);
+ const tierColor =
+ data.tier === "premium"
+ ? "green"
+ : data.tier === "supporter"
+ ? "cyan"
+ : undefined;
+
return (
- {data.name}
+
+ {data.name}
+ {tierColor ? (
+ [{tierLabel}]
+ ) : (
+ [{tierLabel}]
+ )}
+
@{data.slug}
{stats && {stats}}
API: {arenaApiBaseUrl}
diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx
index 50a6036..22b0ce8 100644
--- a/src/components/SearchResults.tsx
+++ b/src/components/SearchResults.tsx
@@ -1,4 +1,5 @@
-import { Box, Text } from "ink";
+import { Box, Text, useInput } from "ink";
+import { useState } from "react";
import useSWR from "swr";
import { ArenaError, client, getData } from "../api/client";
import type {
@@ -16,7 +17,9 @@ import { accentColor, mutedColor } from "../lib/theme";
import { BlockItem } from "./BlockItem";
import { useSessionPaletteActive } from "./SessionPaletteContext";
import { Panel, ScreenFrame } from "./ScreenChrome";
-import { ScreenEmpty, ScreenError, ScreenLoading } from "./ScreenStates";
+import { ScreenEmpty, ScreenLoading } from "./ScreenStates";
+
+const BILLING_URL = "https://www.are.na/billing";
type SearchNavigateView =
| { kind: "channel"; slug: string }
@@ -133,10 +136,18 @@ export function SearchResults({
if (error) {
const isPermission = error instanceof ArenaError && error.status === 403;
- const message = isPermission
- ? "Search requires Are.na Premium"
- : error.message;
- return ;
+
+ if (isPermission) {
+ return ;
+ }
+
+ return (
+
+
+ ✕ {error.message}
+
+
+ );
}
if (items.length === 0) {
@@ -189,3 +200,28 @@ export function SearchResults({
);
}
+
+function SearchPremiumGate({ onBack }: { onBack: () => void }) {
+ const paletteActive = useSessionPaletteActive();
+ const [opened, setOpened] = useState(false);
+
+ useInput((input, key) => {
+ if (paletteActive) return;
+ if (input === "q" || key.escape) return onBack();
+ if (input === "o" && !opened) {
+ openUrl(BILLING_URL);
+ setOpened(true);
+ }
+ });
+
+ return (
+
+
+ Search requires an Are.na Premium subscription
+
+ {opened ? `Opened ${BILLING_URL}` : "Press o to open billing page"}
+
+
+
+ );
+}
diff --git a/src/components/WhoamiScreen.tsx b/src/components/WhoamiScreen.tsx
index eb099fe..9d6e4fa 100644
--- a/src/components/WhoamiScreen.tsx
+++ b/src/components/WhoamiScreen.tsx
@@ -1,5 +1,5 @@
import type { User } from "../api/types";
-import { plural } from "../lib/format";
+import { formatTier, plural } from "../lib/format";
import { EntityProfileScreen } from "./EntityProfileScreen";
export function WhoamiScreen({ me, onBack }: { me: User; onBack: () => void }) {
@@ -8,6 +8,7 @@ export function WhoamiScreen({ me, onBack }: { me: User; onBack: () => void }) {
name={me.name}
slug={me.slug}
statsLine={`${plural(me.counts.channels, "channel")} · ${me.counts.following.toLocaleString()} following · ${plural(me.counts.followers, "follower")}`}
+ metaLine={formatTier(me.tier)}
browserUrl={`https://www.are.na/${me.slug}`}
onBack={onBack}
/>
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 800cd16..8447eee 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -1,4 +1,4 @@
-import type { Followable } from "../api/types";
+import type { Followable, UserTier } from "../api/types";
export function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
@@ -66,6 +66,21 @@ export function formatFollowable(item: Followable): string {
}
}
+const TIER_LABELS: Record = {
+ premium: "Premium",
+ supporter: "Supporter",
+ free: "Free",
+ guest: "Guest",
+};
+
+export function formatTier(tier: UserTier): string {
+ return TIER_LABELS[tier] ?? tier;
+}
+
+export function isPremium(tier: UserTier): boolean {
+ return tier === "premium" || tier === "supporter";
+}
+
export function formatFileSize(bytes?: number | null): string | null {
if (!bytes || bytes <= 0) return null;
if (bytes < 1024) return `${bytes} B`;