Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/member-activity-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ui": patch
---

Add Your activity (/me): the member's profile, with links to edit it in Settings, and the events they checked in at across every community.
11 changes: 11 additions & 0 deletions ui/src/components/attendance-record.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ describe("AttendanceRecord", () => {
expect(screen.queryByTestId("home")).toBeNull();
});

it("keeps every community's section, with loading and empty states, when asked to", async () => {
let resolve: (value: unknown) => void = () => {};
harness.getMyAttendance = () => new Promise((done) => (resolve = done));
show({ alwaysShow: true, testId: "me" });
expect(screen.getByTestId("me-loading")).toBeTruthy();

resolve({ events: [], unclassified: [], communities: [] });
expect((await screen.findByTestId("me-empty")).textContent).toBe("No check-ins recorded yet.");
expect(screen.getByTestId("me")).toBeTruthy();
});

it("hides itself when the caller isn't a member of the community", async () => {
harness.getMyAttendance = async () => {
throw new ORPCError("FORBIDDEN", { message: "You are not a member of this community" });
Expand Down
8 changes: 6 additions & 2 deletions ui/src/components/attendance-record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ const REASON_MESSAGES = {
export function AttendanceRecord({
organizationId = null,
reconcile = false,
alwaysShow = false,
testId,
}: {
organizationId?: string | null;
reconcile?: boolean;
/** Keep the section, with its loading and empty states, when showing every community. */
alwaysShow?: boolean;
testId: string;
}) {
const translate = useAppTranslation();
Expand All @@ -55,11 +58,12 @@ export function AttendanceRecord({
void queryClient.invalidateQueries({ queryKey: attendanceQueryKeys.records() });
}, [queryClient, reconciledAt]);
const everyCommunity = !organizationId;
const hideWhenEmpty = everyCommunity && !alwaysShow;

if (record.error instanceof ORPCError && record.error.code === "FORBIDDEN") return null;
if (everyCommunity && record.isPending) return null;
if (hideWhenEmpty && record.isPending) return null;
if (
everyCommunity &&
hideWhenEmpty &&
record.data &&
record.data.communities.length === 0 &&
record.data.events.length === 0 &&
Expand Down
2 changes: 2 additions & 0 deletions ui/src/components/layout/breadcrumbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export function crumbsFor(
return [page(t("dashboard.startCommunity"))];
case "discover":
return [page(t("nav.directory"))];
case "me":
return [page(t("nav.activity"))];
case "orgs": {
if (!second) return [page(ORGS.label)];
if (second === "new") return [ORGS, page(t("nav.newOrganization"))];
Expand Down
1 change: 1 addition & 0 deletions ui/src/components/layout/nav-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ describe("breadcrumbs", () => {
it("names pages instead of echoing path segments", () => {
expect(labels("/dashboard")).toEqual(["Home"]);
expect(labels("/discover")).toEqual(["Directory"]);
expect(labels("/me")).toEqual(["Your activity"]);
expect(labels("/dashboard/node/proposals")).toEqual(["My community", "Proposals"]);
expect(labels("/nodes/abc/content")).toEqual(["My community", "Events"]);
expect(
Expand Down
12 changes: 11 additions & 1 deletion ui/src/components/layout/user-nav-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { GearIcon, SignOutIcon, UserCircleIcon, UserIcon } from "@phosphor-icons/react";
import {
CalendarCheckIcon,
GearIcon,
SignOutIcon,
UserCircleIcon,
UserIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import type { Organization } from "@/app";
import { pluginPath } from "@/app";
Expand Down Expand Up @@ -62,6 +68,10 @@ export function UserNavMenuContent({
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem render={<Link to="/me" />} data-testid="account.activity-menuitem">
<CalendarCheckIcon />
{translate("nav.activity")}
</DropdownMenuItem>
{nearAccountId && (
<DropdownMenuItem
render={<Link to="/$accountId" params={{ accountId: nearAccountId }} />}
Expand Down
9 changes: 9 additions & 0 deletions ui/src/i18n/messages/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,14 @@ export const commonMessages = createMessageCatalogs(["en", "es", "fr", "zh"], {
"label.voteAbstain": ["Abstain", "Abstenerse", "Abstention", "弃权"],
"label.voteAgainst": ["Against", "En contra", "Contre", "反对"],
"label.voteFor": ["For", "A favor", "Pour", "赞成"],
"me.description": [
"Your profile and the events you checked in at, across every community.",
"Tu perfil y los eventos en los que te registraste, en todas tus comunidades.",
"Votre profil et les événements auxquels vous vous êtes enregistré, dans toutes vos communautés.",
"你的资料,以及你在所有社区签到过的活动。",
],
"me.editProfile": ["Edit profile", "Editar perfil", "Modifier le profil", "编辑资料"],
"me.publicProfile": ["Public profile", "Perfil público", "Profil public", "公开资料"],
"measurement.copyLink": ["Copied a link", "Copió un enlace", "Copie d’un lien", "复制链接"],
"measurement.openCommunity": [
"Opened a community",
Expand All @@ -1291,6 +1299,7 @@ export const commonMessages = createMessageCatalogs(["en", "es", "fr", "zh"], {
"Ouverture d’Explorer",
"打开探索页面",
],
"nav.activity": ["Your activity", "Tu actividad", "Votre activité", "你的动态"],
"nav.admin": ["Admin", "Administración", "Administration", "管理"],
"nav.apiKeys": ["API keys", "Claves API", "Clés API", "API 密钥"],
"nav.community": ["Community", "Comunidad", "Communauté", "社区"],
Expand Down
97 changes: 97 additions & 0 deletions ui/src/routes/_authenticated/_dashboard/-me.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @vitest-environment jsdom

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "@/i18n/test-render";
import { MePage } from "./me";

const identity = vi.hoisted(() => ({
nearAccountId: null as string | null,
avatarSrc: undefined as string | undefined,
displayName: "Ada",
handle: "ada@example.com",
showHandle: true,
initials: "A",
}));

vi.mock("@/components/layout/use-identity", () => ({ useIdentity: () => identity }));

vi.mock("@/app", () => ({
pluginPath: (path: string) => path,
useApiClient: () => ({
getMyAttendance: async () => ({
events: [
{
organizationId: "org-north",
communityName: "North",
eventId: "e1",
eventName: "Builders Night",
checkedInAt: "2026-10-10T18:30:00.000Z",
startsAt: null,
},
],
unclassified: [],
communities: [],
}),
}),
}));

vi.mock("@tanstack/react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("@tanstack/react-router")>()),
Link: ({
to,
params,
children,
...rest
}: {
to: string;
params?: Record<string, string>;
children?: ReactNode;
}) => (
<a
href={Object.entries(params ?? {}).reduce(
(href, [key, value]) => href.replace(`$${key}`, value),
to,
)}
{...rest}
>
{children}
</a>
),
}));

function show() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MePage />
</QueryClientProvider>,
);
}

afterEach(() => {
cleanup();
identity.nearAccountId = null;
});

describe("the member's own page", () => {
it("shows who they are, where to edit the profile, and their events across communities", async () => {
show();
expect(screen.getByTestId("me.header").textContent).toContain("Ada");
expect(screen.getByText("ada@example.com")).toBeTruthy();
expect(screen.getByTestId("me.edit-profile").getAttribute("href")).toBe("/settings/profile");
expect(screen.queryByTestId("me.public-profile")).toBeNull();

const row = await screen.findByTestId("me.attendance-event");
expect(row.textContent).toContain("Builders Night");
expect(row.textContent).toContain("North");
});

it("links the public profile when the member has a NEAR account", () => {
identity.nearAccountId = "builder.testnet";
show();
expect(screen.getByTestId("me.public-profile").getAttribute("href")).toBe("/builder.testnet");
});
});
84 changes: 84 additions & 0 deletions ui/src/routes/_authenticated/_dashboard/me.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { GearIcon, UserCircleIcon, UserIcon } from "@phosphor-icons/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { pluginPath } from "@/app";
import { AttendanceRecord } from "@/components/attendance-record";
import { PageContainer } from "@/components/layout/page-container";
import { PageHeader } from "@/components/layout/page-header";
import { useIdentity } from "@/components/layout/use-identity";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { resolveAppLocale, translateAppMessage, useAppTranslation } from "@/i18n/runtime";
import { pageTitle } from "@/lib/page-title";

export const Route = createFileRoute("/_authenticated/_dashboard/me")({
head: ({ match }) => ({
meta: [
{
title: pageTitle(
translateAppMessage(
"nav.activity",
undefined,
resolveAppLocale(undefined, match.context.locale),
),
match.context.runtimeConfig,
),
},
],
}),
component: MePage,
});

/**
* The member's own page: who they are, with links to edit the profile the auth
* plugin keeps in Settings, and the events they checked in at across communities.
*/
export function MePage() {
const translate = useAppTranslation();
const { nearAccountId, avatarSrc, displayName, handle, showHandle, initials } = useIdentity();

return (
<PageContainer variant="default">
<div className="flex flex-col gap-10">
<PageHeader
headerTestId="me.header"
title={
<span className="flex min-w-0 items-center gap-4">
<Avatar size="lg" className="shrink-0">
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
<AvatarFallback>{initials || <UserIcon className="size-4" />}</AvatarFallback>
</Avatar>
<span className="min-w-0 wrap-anywhere">{displayName}</span>
</span>
}
subtitle={showHandle ? handle : undefined}
description={translate("me.description")}
actions={
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
nativeButton={false}
render={<Link to={pluginPath("/settings/profile")} />}
data-testid="me.edit-profile"
>
<GearIcon />
{translate("me.editProfile")}
</Button>
{nearAccountId && (
<Button
variant="outline"
nativeButton={false}
render={<Link to="/$accountId" params={{ accountId: nearAccountId }} />}
data-testid="me.public-profile"
>
<UserCircleIcon />
{translate("me.publicProfile")}
</Button>
)}
</div>
}
/>
<AttendanceRecord alwaysShow testId="me.attendance" />
</div>
</PageContainer>
);
}
Loading