Show member profiles

Let members choose a name and picture, show them on leaderboards, and build profile pages.

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.

Turn profiles on

Open the settings

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

Choose who sees profiles

OptionLeaderboard rowsReads keyed by a profile
Private (default)Board-specific aliases, as beforeClosed to everyone but the member
Signed-in participantsNames and pictures for signed-in members; aliases for signed-out visitorsOpen to signed-in members
AnyoneNames and pictures for everyoneOpen to signed-out visitors too, where the read's own resource allows it

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.

Save with a reason

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

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.

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.

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 });
FieldAccepts
displayName1 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.
hiddentrue 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

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.

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

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.

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:

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

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:

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

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.

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:

ReadReturnsAlso 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.
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

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:

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

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.

Hookdata
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.
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

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

RouteMethodSession
/v1/me/profileGETMember
/v1/me/profilePOSTMember, with the browser CSRF header
/v1/me/profile/avatarPOSTMember, with the browser CSRF header
/v1/profiles/:idGETOptional, as the settings allow
/v1/profiles/:id/completionsGETOptional, as the settings allow
/v1/profiles/:id/referrals/:programGETMember
/v1/leaderboards/:board/profiles/:idGETOptional, as the settings and the board's access allow
/avatars/:fileGETNone: 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.

On this page