# Show member profiles (/docs/build/profiles)



A profile is the name and picture other members see for someone in your project, for example on a leaderboard. Profiles are private until you turn them on. Members can always read and edit their own.

* **What members see.** Names and pictures on leaderboard rows, and on any profile page your app shows. Each member can pick a name, upload a picture, or hide their profile.
* **What you do in Console.** Choose who sees profiles and where default names and pictures come from, under **Settings → Profiles**. Reset a name, remove a picture or lock a profile from the member's page.
* **What your developer does.** Adds the profile editor and profile pages to your app.

Domino provides the profile and a few reads keyed by its public ID. Your app composes them into whatever page it needs.

```mermaid
flowchart LR
    Member["Member edits name and picture"] --> Profile
    Settings["Operator's profile settings"] --> Profile
    Profile --> Boards["Leaderboard rows"]
    Profile --> Reads["Reads keyed by the profile ID"]
    Reads --> Page["Your app's profile page"]
```

## Turn profiles on [#turn-profiles-on]

### 1. Open the settings [#1-open-the-settings]

In Console, open **Settings → Profiles** in test. You need publish permission.

> [!NOTE] Live settings are read-only
> Profile settings are a definition. Live refuses changes to them from every credential, Console included, with `promotion-required`, so change them in test and [promote](/docs/concepts/promotion) them to live. See [test and live](/docs/concepts/environments).

### 2. Choose who sees profiles [#2-choose-who-sees-profiles]

| Option                     | Leaderboard rows                                                          | Reads keyed by a profile                                                 |
| -------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Private** (default)      | Board-specific aliases, as before                                         | Closed to everyone but the member                                        |
| **Signed-in participants** | Names and pictures for signed-in members; aliases for signed-out visitors | Open to signed-in members                                                |
| **Anyone**                 | Names and pictures for everyone                                           | Open to signed-out visitors too, where the read's own resource allows it |

### 3. Choose default names and pictures [#3-choose-default-names-and-pictures]

Under **Default names and pictures**, check the accounts that fill in a member's name and picture until they choose their own: **X**, **Discord**, or both. Domino uses the first checked account the member connected that has a name or picture. With both checked, use the arrow buttons to put one first. With none checked, the default, members start with a generated name such as `Participant 3fK9x2Qa` and a generated picture.

Only X and Discord can be defaults, because their profiles are public personas. Google and Apple accounts are usually a legal name and a personal photo, so members can only choose them in their own editor.

### 4. Save with a reason [#4-save-with-a-reason]

Enter a reason and select **Save**. The audit log records it. If someone saved in between, reload and try again.

> [!DEV] Configure from code or the CLI
> From a trusted server, `management.profiles.configure({ visibility, defaults, expectedRevision, reason })` takes the same choice, such as `defaults: ["discord", "x"]`, and needs publish permission. From the CLI, `domino profiles settings` shows the current settings and revision, and `domino profiles configure settings.json` takes a file with those four fields.

> [!NOTE] Existing accounts
> Domino stores the names and pictures of X, Discord and Google accounts when a member signs in or connects the account. Members who connected before this release show them after their next sign-in or connection.

## Moderate profiles [#moderate-profiles]

Reviewers moderate in Console: open the member, then use **Reset name**, **Remove picture**, or **Lock profile**. Each needs a reason and appears in the audit log. Find a member by their profile name or profile ID in **Members** search. A removed picture is replaced by the generated one; a locked profile keeps its name and picture until you unlock it.

> [!DEV]
> From a trusted server, `management.profiles.moderate(member, { resetName, removeAvatar, lock, reason })` needs review permission, as does `domino profiles moderate MEMBER --reset-name --remove-avatar --lock --reason "…"` (pass any of the three actions; `--unlock` unlocks).

## Let members edit their profile [#let-members-edit-their-profile]

The signed-in member reads their profile with `relay.profiles.mine()` and changes it with `relay.profiles.update()`. Omitted fields stay as they are; `null` returns to the default.

```ts
import { avatarSrc } from "@domino-sdk/relay/avatar";
import { createBrowserRelayClient } from "@domino-sdk/relay/browser";

const relay = createBrowserRelayClient({ baseUrl: "/relay" });
const profile = await relay.profiles.mine();
console.log(profile.name, avatarSrc(profile.avatar), profile.visibility);

await relay.profiles.update({ displayName: "Ada" });
await relay.profiles.update({ avatar: { source: "account", provider: "x" } });
await relay.profiles.update({ displayName: null, avatar: null });
await relay.profiles.update({ hidden: true });
```

| Field         | Accepts                                                                                                                                   |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `displayName` | 1 to 40 visible characters after trimming; whitespace collapses. Control, zero-width and bidirectional formatting characters are refused. |
| `avatar`      | `{ source: "account", provider: "x" \| "discord" \| "google" }` for a connected account's picture, `{ source: "generated" }`, or `null`.  |
| `hidden`      | `true` shows an alias to everyone else, whatever the project's settings.                                                                  |

`profile.options` lists the member's connected X, Discord and Google accounts, each with its `name`, `givenName` and `imageUrl`. Choosing an account the member has not connected answers 422.

### Greet the member by name [#greet-the-member-by-name]

A member who signed in with Google can be greeted by their first name without asking for it. Read it from their own profile: `givenName` is the first name Google sent, and `name` the full name. Both are null when the account has none; X and Discord have no `givenName`.

```ts
import { createBrowserRelayClient } from "@domino-sdk/relay/browser";

const relay = createBrowserRelayClient({ baseUrl: "/relay" });
const profile = await relay.profiles.mine();
const google = profile.options.find((option) => option.provider === "google");
const firstName = profile.displayName ?? google?.givenName ?? null;

console.log(firstName ? `Hi ${firstName}!` : "Hi there!");
```

Only the member's own session reads these. Profile pages, leaderboard rows and other members never show a Google name: Google can't be a default, so it appears only if the member types it as their display name.

### Upload a picture [#upload-a-picture]

```ts
import { avatarSrc } from "@domino-sdk/relay/avatar";
import { createBrowserRelayClient } from "@domino-sdk/relay/browser";

const relay = createBrowserRelayClient({ baseUrl: "/relay" });
const input = document.querySelector<HTMLInputElement>("#avatar")!;
const file = input.files?.[0];

if (file) {
  const updated = await relay.profiles.uploadAvatar(file);
  console.log(avatarSrc(updated.avatar));
}
```

* **Formats and limits.** JPEG, PNG or WebP, at most 5 MB and 25 megapixels. Larger files answer 413; a file whose content does not match its type, or that cannot be decoded, answers 400.
* **What is stored.** The first frame, rotated upright, center-cropped to 512×512 and stored as WebP without its metadata, so camera and location data are removed.
* **Retries.** The client never resends an avatar upload on its own. Pass the same `{ uploadId }` and file to retry one whose outcome is unknown without creating another picture, before any other upload: replaying an upload a newer one replaced makes the older picture current again. The client makes an ID up when you omit it.
* **Replacing.** A new upload, choosing another avatar, or an operator removing it deletes the previous upload. Browsers may keep showing it for up to an hour.

Upload answers 503 "Avatar uploads are not configured" where Domino has no image storage, including the local development stack.

> [!IMPORTANT] Locked profiles
> An operator can lock a profile. Changing the name or picture then answers 403 until they unlock it; the member can still hide the profile.

In React, `useMyProfile()` returns the profile in `data` with `update`, `uploadAvatar`, `isPending` and `actionError`. It reads again on window focus and after each change, and binds each change to the session it read the profile with:

```tsx
import { avatarSrc } from "@domino-sdk/relay/avatar";
import { useMyProfile } from "@domino-sdk/relay/react";

export function ProfileSettings() {
  const { data: profile, isPending, actionError, update, uploadAvatar } = useMyProfile();

  if (!profile) {
    return null;
  }

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        const name = new FormData(event.currentTarget).get("name");
        void update({ displayName: typeof name === "string" && name ? name : null });
      }}
    >
      <img src={avatarSrc(profile.avatar)} alt="" width={64} height={64} />
      <input
        type="file"
        accept="image/jpeg,image/png,image/webp"
        disabled={isPending || profile.locked}
        onChange={(event) => {
          const file = event.target.files?.[0];

          if (file) {
            void uploadAvatar(file);
          }
        }}
      />
      <input name="name" defaultValue={profile.displayName ?? ""} disabled={profile.locked} />
      <button disabled={isPending}>Save</button>
      {actionError && <p role="alert">{actionError.message}</p>}
    </form>
  );
}
```

## Show avatars [#show-avatars]

Every member has a picture, even before they choose one. Every avatar has a `source` and a `seed`. `avatarSrc(avatar)` returns the picture's URL, or draws the generated avatar from its seed. Pictures from X, Discord and Google load from their sites and can disappear, so fall back to `defaultAvatar(avatar.seed)` when an image fails to load:

```tsx
import type { ProfileAvatar } from "@domino-sdk/relay";
import { avatarSrc, defaultAvatar } from "@domino-sdk/relay/avatar";

export function Avatar({ avatar }: { avatar: ProfileAvatar }) {
  return (
    <img
      src={avatarSrc(avatar)}
      alt=""
      width={32}
      height={32}
      onError={(event) => {
        event.currentTarget.src = defaultAvatar(avatar.seed);
      }}
    />
  );
}
```

The seed is the same `avatarSeed` that `relay.me.get()` returns. Domino derives it from the member with a secret per project, so it stays the same when the member links wallets or accounts, differs between projects, and reveals nothing about the member.

## Leaderboard rows [#leaderboard-rows]

Once profiles are on, leaderboards show members' names and pictures to the viewers your settings allow. Each row has a `profile` where the viewer can see it, and `null` otherwise. When present, `row.name` is the profile's name, so apps that render `row.name` show names without changes. Keep `row.participant` as the row key. See [leaderboards](/docs/build/leaderboards).

## Compose a profile page [#compose-a-profile-page]

A profile page is yours to design. Key it by the profile's public ID (`profile.id` from a leaderboard row or `mine()`), such as `/p/:id`, and combine the reads you need. Each read checks the profile's visibility and then its own resource's access:

| Read                                                | Returns                                                                                                                                                                                                                        | Also needs                                                                            |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `relay.profiles.get(id)`                            | The profile's `id`, `name` and `avatar`.                                                                                                                                                                                       | Nothing else.                                                                         |
| `relay.leaderboards.placement(board, id)`           | `rank`, `score` and the ranked `total` on one board, calculated now. `rank` and `score` are `null` while the member is unranked there, such as when excluded.                                                                  | The board's access: a board without public access answers 401 to signed-out visitors. |
| `relay.profiles.completions(id, { limit, cursor })` | Completions grouped by quest, most recent first, with `completions` and `lastCompletedAt` (null for completions recorded before dates were). Pass `nextCursor` as `cursor` for the next page; `limit` is 1 to 100, default 20. | Only quests that opt in with `profile: { visible: true }`.                            |
| `relay.profiles.referral(id, program)`              | `invited` members, `qualified` invitations and `milestones` in one referral program. Never codes or accounts.                                                                                                                  | A signed-in member: signed-out visitors get 401.                                      |

```ts
import { createBrowserRelayClient } from "@domino-sdk/relay/browser";

const relay = createBrowserRelayClient({ baseUrl: "/relay" });
const id = "profile_3fK9x2Qa7Lm1Vb8Nc4Rt6y";
const profile = await relay.profiles.get(id);
const placement = await relay.leaderboards.placement("season-1", id);
console.log(profile.name, placement.rank ?? "Unranked", "of", placement.total);

const page = await relay.profiles.completions(id, { limit: 10 });

for (const quest of page.items) {
  console.log(quest.title, quest.completions, quest.lastCompletedAt);
}

const referral = await relay.profiles.referral(id, "invite-friends");
console.log(referral.qualified, "of", referral.invited, "qualified");
```

A missing profile, a hidden one, and one the viewer cannot see all answer the same 404 "Profile not found", on every read. A board or program that does not exist answers 404 too.

### Show completions on profiles [#show-completions-on-profiles]

Quest completions stay off profiles unless the quest opts in, because a completion can reveal what a member holds or did. Set `profile` in the quest definition and publish:

```ts
import { defineQuest, manual, once, settings } from "@domino-sdk/relay/authoring";

export default defineQuest({
  key: "join-the-community-call",
  title: "Join the community call",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [],
  profile: { visible: true },
});
```

The quest's active release decides: publishing it with `profile: { visible: false }`, or without `profile`, removes its completions from profiles, including earlier ones. Archiving the quest removes them too, and unarchiving brings them back. The flag is part of the quest's code; Console does not change it. It also applies to a quest with `visibility: "hidden"`: its completions and title appear on profiles although members' quest lists leave it out.

Leaderboard prize completions are operator accounting records, not quest activity. They stay in the leaderboard settlement review and do not appear in a member's profile completions or quest progress.

### In React [#in-react]

Each read has a hook that returns `{ status, data, error, refresh }`. It reads again for each member who signs in or out, after the member changes their profile, and when a new completion appears in the quest experience. A profile that is missing, hidden or not visible to this viewer is `status: "error"` with `error.status` 404, the same for all three.

| Hook                                                 | `data`                                                                                                |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `useProfile(id, { intervalMs })`                     | The profile's name and avatar. Reads again every 60 seconds by default while the page is visible.     |
| `useLeaderboardPlacement(board, id, { intervalMs })` | The rank and score. Reads again every 30 seconds by default.                                          |
| `useProfileReferral(id, program, { intervalMs })`    | The invited and qualified counts and milestones. Needs a signed-in member: `signed-out` otherwise.    |
| `useProfileCompletions(id, { limit })`               | `items` and `nextCursor`. `loadMore()` appends the next page, with `loadingMore` and `loadMoreError`. |

```tsx
import { avatarSrc } from "@domino-sdk/relay/avatar";
import { useProfile, useProfileCompletions } from "@domino-sdk/relay/react";

export function ProfilePage({ id }: { id: string }) {
  const profile = useProfile(id);
  const completions = useProfileCompletions(id);

  if (profile.status === "error" && profile.error.status === 404) {
    return <p>This profile is not available.</p>;
  }

  if (profile.status !== "ready") {
    return null;
  }

  return (
    <section>
      <img src={avatarSrc(profile.data.avatar)} alt="" width={64} height={64} />
      <h1>{profile.data.name}</h1>
      {completions.status === "ready" && (
        <ul>
          {completions.data.items.map((quest) => (
            <li key={quest.quest}>
              {quest.title} × {quest.completions}
            </li>
          ))}
        </ul>
      )}
      {completions.status === "ready" && completions.data.nextCursor !== null && (
        <button disabled={completions.loadingMore} onClick={() => void completions.loadMore()}>
          Show more
        </button>
      )}
    </section>
  );
}
```

Profiles and leaderboard rows never contain emails, wallet addresses, member IDs or account subjects.

## Routes and proxies [#routes-and-proxies]

This section is for developers who run their own proxy or call the participant API directly.

| Route                                  | Method | Session                                                |
| -------------------------------------- | ------ | ------------------------------------------------------ |
| `/v1/me/profile`                       | GET    | Member                                                 |
| `/v1/me/profile`                       | POST   | Member, with the browser CSRF header                   |
| `/v1/me/profile/avatar`                | POST   | Member, with the browser CSRF header                   |
| `/v1/profiles/:id`                     | GET    | Optional, as the settings allow                        |
| `/v1/profiles/:id/completions`         | GET    | Optional, as the settings allow                        |
| `/v1/profiles/:id/referrals/:program`  | GET    | Member                                                 |
| `/v1/leaderboards/:board/profiles/:id` | GET    | Optional, as the settings and the board's access allow |
| `/avatars/:file`                       | GET    | None: uploaded pictures, cacheable for an hour         |

`proxyParticipant` and existing-app bridges admit these routes in the `@domino-sdk/relay` release that adds each client method. An app whose proxy runs an older SDK gets 404 for them until it upgrades. Through an existing-app bridge (`createParticipantBackend`), every request needs the application's own sign-in, so profile reads there are for signed-in visitors only.
