# Wall Feed

Activity feed for a profile wall: posts, boosts, reactions, follows, and photo albums in one grouped stream.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/wall-feed.json
```

[Registry JSON](https://ui.uptoolkit.com/r/wall-feed.json)

## Preview

```tsx
import { WallFeed } from "@/components/wall-feed";

export function Preview() {
  return <WallFeed showComments />;
}
```


## Source

### components/wall-feed.tsx

```tsx
"use client";

import {
  IconCalendarPlus,
  IconHeartFilled,
  IconPhoto,
  IconUserPlus,
  IconUsersGroup,
} from "@tabler/icons-react";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

import { ActorAvatar } from "@/components/ui/actor-avatar";
import { CommentThread } from "@/components/ui/comment-thread";
import { JsonLd } from "@/components/json-ld";
import { PostCard } from "@/components/ui/post-card";
import { PostComposer } from "@/components/ui/post-composer";
import {
  formatPublishedTime,
  getActorDisplayName,
  getCollectionItems,
  toPlainText,
  type ActivityPubActivity,
  type ActivityPubActor,
  type ActivityPubCollection,
  type ActivityPubDocument,
  type ActivityPubObject,
} from "@/lib/activitypub";
import { toFeedJsonLd } from "@/lib/schema-org";
import {
  SAMPLE_NOW,
  sampleAuthor,
  sampleViewer,
} from "@/lib/social-sample-data";
import {
  countWallEntries,
  filterWallEntries,
  groupWallEntries,
  sampleWallCollection,
  toWallEntries,
  type WallActivity,
  type WallEntry,
  type WallEntryKind,
  type WallFilter,
} from "@/lib/wall-activities";

/** Copy and iconography for the collapsed, non-post rows. */
/** The kinds that collapse to a single line. `post` and `share` render as cards. */
type WallRowKind = Exclude<WallEntryKind, "post" | "share">;

const kindConfig: Record<
  WallRowKind,
  { icon: typeof IconHeartFilled; tone: string; verb: string }
> = {
  reaction: {
    icon: IconHeartFilled,
    tone: "text-rose-600 dark:text-rose-400",
    verb: "reacted to a post",
  },
  follow: {
    icon: IconUserPlus,
    tone: "text-sky-600 dark:text-sky-400",
    verb: "started following",
  },
  album: {
    icon: IconPhoto,
    tone: "text-amber-600 dark:text-amber-400",
    verb: "added photos to an album",
  },
  profile: {
    icon: IconCalendarPlus,
    tone: "text-violet-600 dark:text-violet-400",
    verb: "updated their profile",
  },
  join: {
    icon: IconUsersGroup,
    tone: "text-teal-600 dark:text-teal-400",
    verb: "joined a group",
  },
};

const filterLabels = {
  all: "All activity",
  posts: "Posts",
  photos: "Photos",
  people: "People",
  wall: "Wall posts",
} as const satisfies Record<WallFilter, string>;

const filterOrder = [
  "all",
  "posts",
  "photos",
  "people",
  "wall",
] as const satisfies readonly WallFilter[];

function isWallFilter(value: unknown): value is WallFilter {
  return typeof value === "string" && filterOrder.some((filter) => filter === value);
}

type WallFeedProps = Omit<React.ComponentProps<"section">, "children"> & {
  /** The actor whose wall this is. Drives addressing and the visitor split. */
  owner?: ActivityPubActor;
  /** The signed-in actor. Omit for a signed-out, read-only wall. */
  viewer?: ActivityPubActor;
  /** An `outbox` page of wall activities. */
  collection?: ActivityPubCollection<WallActivity> | readonly WallActivity[];
  heading?: string;
  defaultFilter?: WallFilter;
  /** Hide the filter row, for an embedded or single-purpose wall. */
  hideFilters?: boolean;
  /** Hide the composer, for a wall that does not accept visitor posts. */
  hideComposer?: boolean;
  /** Show an inline comment thread under each post. */
  showComments?: boolean;
  /** Called with the `Create` activity from the composer. */
  onPublish?: (activity: ActivityPubActivity<ActivityPubObject>) => void | Promise<void>;
  /** Called with the collection's `next` page URL. */
  onLoadMore?: (next: string) => void | Promise<void>;
  /** Any activity a post emits: Like, EmojiReact, Announce, or the Undo. */
  onActivity?: (activity: ActivityPubActivity<unknown>) => void;
  /** Called when a collapsed activity row is selected. */
  onSelect?: (entry: WallEntry) => void;
  /** Fixed "now" for relative timestamps. */
  now?: Date | number;
  /** BCP 47 locale for dates and counts. Must match on server and client. */
  locale?: string;
};

/**
 * Activity feed for a profile wall.
 *
 * Unlike a timeline, this renders the whole activity vocabulary: `Create` and
 * `Announce` become full post cards, while reactions, follows, album additions,
 * profile changes, and group joins collapse into single attributed lines. The
 * stream is bucketed by day and described by one Schema.org `CollectionPage`.
 */
function WallFeed({
  owner = sampleAuthor,
  viewer = sampleViewer,
  collection = sampleWallCollection,
  heading,
  defaultFilter = "all",
  hideFilters = false,
  hideComposer = false,
  showComments = false,
  onPublish,
  onLoadMore,
  onActivity,
  onSelect,
  now = SAMPLE_NOW,
  locale,
  className,
  ...props
}: WallFeedProps) {
  const [filter, setFilter] = React.useState<WallFilter>(defaultFilter);
  /** Posts written in this session, kept until the caller refetches. */
  const [published, setPublished] = React.useState<WallActivity[]>([]);
  const [loadingMore, setLoadingMore] = React.useState(false);

  const ownerName = getActorDisplayName(owner);
  const label = heading ?? `${ownerName}'s wall`;
  const next = collection && "type" in collection ? collection.next : undefined;

  const entries = React.useMemo(
    () => toWallEntries([...published, ...getCollectionItems(collection)], owner),
    [collection, owner, published],
  );

  const visible = React.useMemo(() => filterWallEntries(entries, filter), [entries, filter]);
  const groups = React.useMemo(
    () => groupWallEntries(visible, { now, ...(locale === undefined ? {} : { locale }) }),
    [visible, now, locale],
  );

  // Only the full post cards belong in the CollectionPage; a "liked a post"
  // row is not itself a posting.
  const postings = React.useMemo(
    () =>
      visible.flatMap((entry) =>
        (entry.kind === "post" || entry.kind === "share") && entry.object ? [entry.object] : [],
      ),
    [visible],
  );

  const counts = React.useMemo(() => countWallEntries(entries), [entries]);

  const handlePublish = React.useCallback(
    async (activity: ActivityPubActivity<ActivityPubObject>) => {
      setPublished((current) => [activity, ...current]);
      await onPublish?.(activity);
    },
    [onPublish],
  );

  const handleLoadMore = React.useCallback(async () => {
    if (!next || loadingMore) {
      return;
    }

    setLoadingMore(true);

    try {
      await onLoadMore?.(next);
    } finally {
      setLoadingMore(false);
    }
  }, [loadingMore, next, onLoadMore]);

  return (
    <section
      aria-label={label}
      className={cn("flex w-full max-w-xl flex-col gap-4", className)}
      {...props}
    >
      <JsonLd
        data={toFeedJsonLd(postings, {
          name: label,
          ...(collection && "type" in collection && collection.id ? { url: collection.id } : {}),
        })}
      />

      {hideComposer || !viewer ? null : (
        <PostComposer
          author={viewer}
          placeholder={
            viewer.id === owner.id ? "What's on your mind?" : `Write something to ${ownerName}...`
          }
          submitLabel={viewer.id === owner.id ? "Post" : "Post to wall"}
          onSubmit={(activity) => handlePublish(activity)}
        />
      )}

      {hideFilters ? null : (
        <Tabs
          value={filter}
          onValueChange={(value: unknown) => setFilter(isWallFilter(value) ? value : "all")}
        >
          <TabsList
            variant="line"
            className="flex-wrap justify-start group-data-horizontal/tabs:h-auto"
          >
            {filterOrder.map((value) => (
              <TabsTrigger key={value} value={value} disabled={counts[value] === 0}>
                {filterLabels[value]}
                <Badge variant="secondary">{counts[value]}</Badge>
              </TabsTrigger>
            ))}
          </TabsList>
        </Tabs>
      )}

      {groups.length === 0 ? (
        <Card>
          <CardContent className="py-10 text-center">
            <p className="m-0 text-sm font-medium">Nothing here yet</p>
            <p className="m-0 text-sm text-muted-foreground">
              {filter === "all"
                ? `${ownerName} has not posted anything.`
                : "Try a different filter."}
            </p>
          </CardContent>
        </Card>
      ) : (
        groups.map((group) => (
          <section key={group.id} aria-label={group.label} className="flex flex-col gap-3">
            <div className="flex items-center gap-3">
              <h3 className="m-0 text-xs font-medium tracking-wide text-muted-foreground uppercase">
                {group.label}
              </h3>
              <Separator className="flex-1" />
            </div>

            <ol className="m-0 flex list-none flex-col gap-3 p-0">
              {group.entries.map((entry) => {
                const kind = entry.kind;

                return (
                  <li key={entry.activity.id}>
                    {kind === "post" || kind === "share" ? (
                      <WallPostEntry
                        entry={entry}
                        owner={owner}
                        viewer={viewer}
                        showComments={showComments}
                        now={now}
                        {...(locale === undefined ? {} : { locale })}
                        {...(onActivity ? { onActivity } : {})}
                      />
                    ) : (
                      <WallActivityRow
                        entry={entry}
                        kind={kind}
                        now={now}
                        {...(locale === undefined ? {} : { locale })}
                        {...(onSelect ? { onSelect } : {})}
                      />
                    )}
                  </li>
                );
              })}
            </ol>
          </section>
        ))
      )}

      {next ? (
        <Button
          type="button"
          variant="outline"
          className="self-center"
          disabled={loadingMore}
          onClick={() => void handleLoadMore()}
        >
          {loadingMore ? "Loading..." : "Load earlier activity"}
        </Button>
      ) : null}
    </section>
  );
}

/** A full post card, with a "wrote on your wall" ribbon when it is a visitor's. */
function WallPostEntry({
  entry,
  owner,
  viewer,
  showComments,
  now,
  locale,
  onActivity,
}: {
  entry: WallEntry;
  owner: ActivityPubActor;
  viewer?: ActivityPubActor;
  showComments: boolean;
  now: Date | number;
  locale?: string;
  onActivity?: (activity: ActivityPubActivity<unknown>) => void;
}) {
  if (!entry.object) {
    return null;
  }

  const isWallPost = entry.kind === "post" && entry.fromVisitor;

  return (
    <div className="flex flex-col gap-1.5">
      {isWallPost ? (
        <p className="m-0 flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
          <IconUserPlus aria-hidden="true" className="size-3.5" />
          <span>
            <span className="font-medium text-foreground">{getActorDisplayName(entry.actor)}</span>{" "}
            wrote on {getActorDisplayName(owner)}&rsquo;s wall
          </span>
        </p>
      ) : null}

      <PostCard
        object={entry.object}
        activity={{ ...entry.activity, object: entry.object }}
        {...(viewer ? { viewer } : {})}
        now={now}
        {...(locale === undefined ? {} : { locale })}
        {...(onActivity
          ? {
              onLike: onActivity,
              onUnlike: onActivity,
              onShare: onActivity,
              onUnshare: onActivity,
            }
          : {})}
      >
        {showComments ? (
          <>
            <Separator />
            <CommentThread
              object={entry.object}
              {...(viewer ? { viewer } : {})}
              now={now}
              {...(locale === undefined ? {} : { locale })}
              initialCount={2}
            />
          </>
        ) : null}
      </PostCard>
    </div>
  );
}

/** One collapsed line for a reaction, follow, album, profile change, or join. */
function WallActivityRow({
  entry,
  kind,
  now,
  locale,
  onSelect,
}: {
  entry: WallEntry;
  kind: WallRowKind;
  now: Date | number;
  locale?: string;
  onSelect?: (entry: WallEntry) => void;
}) {
  const config = kindConfig[kind];
  const Icon = config.icon;
  const photos = (entry.object?.attachment ?? []).filter(
    (file): file is ActivityPubDocument => file.type === "Image",
  );
  const detail = getEntryDetail(entry);
  const isEmoji = entry.activity.type === "EmojiReact" && Boolean(entry.activity.content);
  const Wrapper = onSelect ? "button" : "div";

  return (
    <Card className="gap-0 py-3">
      <CardContent className="flex flex-col gap-3">
        <Wrapper
          {...(onSelect ? { type: "button" as const, onClick: () => onSelect(entry) } : {})}
          className={cn(
            "flex w-full items-start gap-3 text-left",
            onSelect && "rounded-md transition-colors hover:opacity-80",
          )}
        >
          <span className="relative shrink-0">
            <ActorAvatar actor={entry.actor} size="sm" />
            <span
              className={cn(
                "absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-background ring-1 ring-border",
                config.tone,
              )}
            >
              {isEmoji ? (
                <span aria-hidden="true" className="text-[10px] leading-none">
                  {entry.activity.content}
                </span>
              ) : (
                <Icon aria-hidden="true" className="size-2.5" />
              )}
            </span>
          </span>

          <span className="flex min-w-0 flex-1 flex-col gap-0.5">
            <span className="text-sm">
              <span className="font-semibold">{getActorDisplayName(entry.actor)}</span>{" "}
              <span className="text-muted-foreground">{config.verb}</span>
              {detail ? <span className="font-medium"> {detail}</span> : null}
            </span>
            <time dateTime={entry.published} className="text-xs text-muted-foreground">
              {formatPublishedTime(entry.published, {
                now,
                ...(locale === undefined ? {} : { locale }),
              })}
            </time>
          </span>
        </Wrapper>

        {photos.length > 0 ? (
          <ul className="m-0 grid list-none grid-cols-4 gap-1.5 p-0 sm:grid-cols-5">
            {photos.slice(0, 5).map((photo) => (
              <li key={photo.url} className="overflow-hidden rounded-md border">
                <img
                  src={photo.url}
                  alt={photo.name ?? ""}
                  loading="lazy"
                  className="aspect-square size-full object-cover"
                />
              </li>
            ))}
          </ul>
        ) : null}
      </CardContent>
    </Card>
  );
}

/**
 * The trailing noun for a collapsed row: an album title, the followed actor's
 * name, or a short excerpt of the post that was reacted to.
 */
function getEntryDetail(entry: WallEntry): string | undefined {
  if (entry.kind === "follow" || entry.kind === "join") {
    return entry.target ? getActorDisplayName(entry.target) : undefined;
  }

  if (entry.kind === "album" || entry.kind === "profile") {
    return entry.object?.name ?? undefined;
  }

  const text = toPlainText(entry.object?.content) || entry.object?.name;

  if (!text) {
    return undefined;
  }

  return `“${text.length > 60 ? `${text.slice(0, 59)}…` : text}”`;
}

export { WallFeed, kindConfig, type WallFeedProps };
```


### lib/wall-activities.ts

```ts
/**
 * Wall activity vocabulary and fixtures.
 *
 * A wall stream is wider than a timeline: alongside `Create` and `Announce` it
 * carries the small social signals — reactions, follows, joins, album additions,
 * profile changes — that a plain feed drops on the floor. Everything here is
 * shaped like real ActivityStreams so swapping in an `outbox` page is a matter
 * of replacing {@link sampleWallCollection}.
 */

import {
  ACTIVITY_STREAMS_CONTEXT,
  PUBLIC_AUDIENCE,
  resolveActor,
  type ActivityPubActivity,
  type ActivityPubActor,
  type ActivityPubCollection,
  type ActivityPubDocument,
  type ActivityPubObject,
} from "@/lib/activitypub";
import {
  SAMPLE_NOW,
  articlePost,
  buildCreateActivity,
  mentionPost,
  samplePost,
  sampleAuthor,
  sampleFollowers,
  sampleSuggestions,
  sampleViewer,
} from "@/lib/social-sample-data";

/**
 * Any activity a wall can carry.
 *
 * The object may be a bare IRI, an inlined object, or — for `Follow` and
 * `Join` — the actor or group being acted upon.
 */
export type WallActivity = ActivityPubActivity<ActivityPubObject | ActivityPubActor | string>;

/**
 * How a wall entry is rendered.
 *
 * `post` entries get a full card with reactions and comments; every other kind
 * collapses to a single attributed line.
 */
export type WallEntryKind = "post" | "share" | "reaction" | "follow" | "album" | "profile" | "join";

/** Filter buckets shown above the stream. */
export type WallFilter = "all" | "posts" | "photos" | "people" | "wall";

export type WallEntry = {
  activity: WallActivity;
  actor: ActivityPubActor;
  kind: WallEntryKind;
  /** The resolved object, when the activity carries one inline. */
  object?: ActivityPubObject;
  /** The actor or group acted upon, for `Follow` and `Join`. */
  target?: ActivityPubActor;
  /** True when this is a post someone else wrote *on* the owner's wall. */
  fromVisitor: boolean;
  published: string;
};

/** An actor is the only thing in the vocabulary with a `preferredUsername`. */
function isWallActor(value: WallActivity["object"]): value is ActivityPubActor {
  return typeof value === "object" && value !== null && "preferredUsername" in value;
}

function isWallObject(value: WallActivity["object"]): value is ActivityPubObject {
  return typeof value === "object" && value !== null && "attributedTo" in value;
}

function minutesAgo(minutes: number): string {
  return new Date(SAMPLE_NOW.getTime() - minutes * 60_000).toISOString();
}

function buildImage(seed: string, name: string): ActivityPubDocument {
  return {
    type: "Image",
    mediaType: "image/svg+xml",
    url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}`,
    name,
    width: 600,
    height: 600,
  };
}

/** A note written by a visitor onto the owner's wall. */
function buildWallPost(options: {
  author: ActivityPubActor;
  owner: ActivityPubActor;
  slug: string;
  content: string;
  minutesAgo: number;
  attachment?: readonly ActivityPubDocument[];
  likes?: number;
  shares?: number;
}): ActivityPubObject {
  const id = `${options.author.id}/posts/${options.slug}`;

  return {
    id,
    type: "Note",
    attributedTo: options.author,
    content: options.content,
    published: minutesAgo(options.minutesAgo),
    url: id,
    inLanguage: "en",
    // A wall post is addressed to the owner as well as the public, which is
    // what marks it as "on their wall" rather than a post that merely mentions
    // them.
    to: [PUBLIC_AUDIENCE],
    cc: [options.owner.id],
    audience: [options.owner.id],
    tag: [{ type: "Mention", name: `@${options.owner.preferredUsername}`, href: options.owner.id }],
    ...(options.attachment ? { attachment: options.attachment } : {}),
    likes: { type: "Collection", id: `${id}/likes`, totalItems: options.likes ?? 0 },
    shares: { type: "Collection", id: `${id}/shares`, totalItems: options.shares ?? 0 },
    replies: { type: "Collection", id: `${id}/replies`, totalItems: 0, items: [] },
  };
}

const welcomeWallPost = buildWallPost({
  author: sampleViewer,
  owner: sampleAuthor,
  slug: "wall-welcome",
  minutesAgo: 18,
  content:
    "<p>Congratulations on the atlas launch. Ten years of survey work and it finally has a front door.</p>",
  likes: 34,
  shares: 2,
});

const photoWallPost = buildWallPost({
  author: sampleFollowers[2],
  owner: sampleAuthor,
  slug: "wall-fieldwork-photos",
  minutesAgo: 320,
  content: "<p>Found these from the Svalbard trip. You were holding the theodolite backwards.</p>",
  attachment: [
    buildImage("svalbard-1", "Two surveyors on a snowfield with a theodolite"),
    buildImage("svalbard-2", "A weathered field notebook open on a rock"),
  ],
  likes: 88,
  shares: 4,
});

const albumPhotos: readonly ActivityPubDocument[] = [
  buildImage("atlas-plate-1", "Contour plate one, inked"),
  buildImage("atlas-plate-2", "Contour plate two, inked"),
  buildImage("atlas-plate-3", "Contour plate three, with survey annotations"),
  buildImage("atlas-plate-4", "Contour plate four, coastline detail"),
  buildImage("atlas-plate-5", "Contour plate five, elevation shading"),
];

const photoAlbum: ActivityPubObject = {
  id: `${sampleAuthor.id}/collections/atlas-plates`,
  type: "Page",
  attributedTo: sampleAuthor,
  name: "Atlas plates, 2019-2025",
  content: "<p>Every inked plate from the atlas, in order.</p>",
  published: minutesAgo(1450),
  url: `${sampleAuthor.id}/collections/atlas-plates`,
  attachment: albumPhotos,
};

const coverUpdate: ActivityPubObject = {
  id: `${sampleAuthor.id}#cover`,
  type: "Image",
  attributedTo: sampleAuthor,
  name: "New cover image",
  published: minutesAgo(2600),
  attachment: [buildImage("mira-cover-2025", "Cover image: a stylised contour field")],
};

/**
 * A wall page shaped like an `outbox` response, newest first.
 *
 * Deliberately mixes every activity type the feed knows how to render so the
 * block is a working demonstration the moment it is installed.
 */
export const sampleWallCollection: ActivityPubCollection<WallActivity> = {
  "@context": ACTIVITY_STREAMS_CONTEXT,
  id: `${sampleAuthor.id}/outbox?page=1`,
  type: "OrderedCollection",
  totalItems: 486,
  orderedItems: [
    buildCreateActivity(welcomeWallPost),
    {
      id: `${sampleFollowers[1].id}/activities/like/5512`,
      type: "Like",
      actor: sampleFollowers[1],
      object: samplePost,
      published: minutesAgo(26),
    },
    buildCreateActivity(samplePost),
    {
      id: `${sampleAuthor.id}/activities/announce/881`,
      type: "Announce",
      actor: sampleAuthor,
      object: articlePost,
      published: minutesAgo(96),
    },
    {
      id: `${sampleSuggestions[0].id}/activities/follow/301`,
      type: "Follow",
      actor: sampleSuggestions[0],
      object: sampleAuthor.id,
      published: minutesAgo(140),
    },
    buildCreateActivity(photoWallPost),
    {
      id: `${sampleFollowers[3].id}/activities/emojireact/992`,
      type: "EmojiReact",
      actor: sampleFollowers[3],
      content: "\u{1F5FA}\u{FE0F}",
      object: samplePost,
      published: minutesAgo(400),
    },
    buildCreateActivity(mentionPost),
    {
      id: `${sampleAuthor.id}/activities/add/77`,
      type: "Add",
      actor: sampleAuthor,
      object: photoAlbum,
      target: `${sampleAuthor.id}/collections/atlas-plates`,
      published: minutesAgo(1450),
    },
    {
      id: `${sampleAuthor.id}/activities/join/4`,
      type: "Join",
      actor: sampleAuthor,
      object: sampleFollowers[4],
      published: minutesAgo(2100),
    },
    {
      id: `${sampleAuthor.id}/activities/update/12`,
      type: "Update",
      actor: sampleAuthor,
      object: coverUpdate,
      published: minutesAgo(2600),
    },
  ],
  next: `${sampleAuthor.id}/outbox?page=2`,
};

/** Classifies an activity into the row the wall renders for it. */
export function getWallEntryKind(activity: WallActivity): WallEntryKind | undefined {
  switch (activity.type) {
    case "Create":
      return "post";
    case "Announce":
      return "share";
    case "Like":
    case "EmojiReact":
      return "reaction";
    case "Follow":
      return "follow";
    case "Add":
      return "album";
    case "Update":
      return "profile";
    case "Join":
      return "join";
    default:
      return undefined;
  }
}

/**
 * A post is "on the wall" when it is addressed to the owner by someone else,
 * which is how the visitor/owner split is drawn without a second field.
 */
function isVisitorPost(object: ActivityPubObject | undefined, ownerId: string): boolean {
  if (!object) {
    return false;
  }

  return [...(object.cc ?? []), ...(object.audience ?? [])].includes(ownerId);
}

/** Resolves a collection of raw activities into renderable wall entries. */
export function toWallEntries(
  activities: readonly WallActivity[],
  owner: ActivityPubActor,
): WallEntry[] {
  return activities.flatMap((activity) => {
    const kind = getWallEntryKind(activity);
    const actor = resolveActor(activity.actor);

    if (!kind || !actor) {
      return [];
    }

    const object = isWallObject(activity.object) ? activity.object : undefined;
    // A `Follow` on someone's own wall usually addresses them by bare IRI, so
    // resolve that back to the owner rather than rendering a verb with no noun.
    const target = isWallActor(activity.object)
      ? activity.object
      : activity.object === owner.id
        ? owner
        : undefined;
    const published = activity.published ?? object?.published;

    if (!published) {
      return [];
    }

    return [
      {
        activity,
        actor,
        kind,
        ...(object ? { object } : {}),
        ...(target ? { target } : {}),
        fromVisitor: actor.id !== owner.id && isVisitorPost(object, owner.id),
        published,
      },
    ];
  });
}

const filterPredicates = {
  all: () => true,
  posts: (entry: WallEntry) => entry.kind === "post" || entry.kind === "share",
  photos: (entry: WallEntry) =>
    entry.kind === "album" ||
    entry.kind === "profile" ||
    (entry.object?.attachment ?? []).some((file) => file.type === "Image"),
  people: (entry: WallEntry) => entry.kind === "follow" || entry.kind === "join",
  wall: (entry: WallEntry) => entry.kind === "post" && entry.fromVisitor,
} as const satisfies Record<WallFilter, (entry: WallEntry) => boolean>;

export function filterWallEntries(entries: readonly WallEntry[], filter: WallFilter): WallEntry[] {
  return entries.filter(filterPredicates[filter]);
}

/** Size of every filter bucket, so the filter row can label and disable tabs. */
export function countWallEntries(entries: readonly WallEntry[]): Record<WallFilter, number> {
  return {
    all: filterWallEntries(entries, "all").length,
    posts: filterWallEntries(entries, "posts").length,
    photos: filterWallEntries(entries, "photos").length,
    people: filterWallEntries(entries, "people").length,
    wall: filterWallEntries(entries, "wall").length,
  };
}

export type WallEntryGroup = {
  /** Stable key derived from the calendar day, safe for SSR. */
  id: string;
  label: string;
  entries: WallEntry[];
};

/**
 * Buckets entries into "Today", "Yesterday", then month headings.
 *
 * The label is computed from `now` rather than the wall clock so a server render
 * and its hydration agree.
 */
export function groupWallEntries(
  entries: readonly WallEntry[],
  options: { now?: Date | number; locale?: string } = {},
): WallEntryGroup[] {
  const now = new Date(options.now ?? SAMPLE_NOW);
  const today = toDayKey(now);
  const yesterday = toDayKey(new Date(now.getTime() - 86_400_000));
  const groups: WallEntryGroup[] = [];

  for (const entry of entries) {
    const date = new Date(entry.published);
    const dayKey = toDayKey(date);
    const id = dayKey === today || dayKey === yesterday ? dayKey : toMonthKey(date);
    const label =
      dayKey === today
        ? "Today"
        : dayKey === yesterday
          ? "Yesterday"
          : formatMonth(date, options.locale);
    const current = groups.at(-1);

    if (current?.id === id) {
      current.entries.push(entry);
    } else {
      groups.push({ id, label, entries: [entry] });
    }
  }

  return groups;
}

function toDayKey(date: Date): string {
  return date.toISOString().slice(0, 10);
}

function toMonthKey(date: Date): string {
  return date.toISOString().slice(0, 7);
}

function formatMonth(date: Date, locale?: string): string {
  return new Intl.DateTimeFormat(locale ?? "en-US", {
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  }).format(date);
}
```



## Usage

A wall is wider than a timeline. Alongside `Create` and `Announce` it carries the small signals a
plain feed throws away — reactions, follows, album additions, profile changes, group joins — and
this block renders all of them in one stream.

```tsx
import { WallFeed } from "@/components/wall-feed";

const outbox = await fetchOutbox(owner);

<WallFeed
  owner={owner}
  viewer={viewer}
  collection={outbox}
  showComments
  onPublish={(activity) => postToOutbox(viewer, activity)}
  onLoadMore={(next) => fetchPage(next)}
/>
```

### Two row shapes

`Create` and `Announce` render as full [`post-card`](/components/post-card) cards with reactions and
an optional comment thread. Everything else collapses to a single attributed line with an avatar, a
verb, and a timestamp — the same visual language as
[`notifications-panel`](/blocks/notifications-panel), so the two read as one system. An `Add` of a
photo album additionally renders a thumbnail strip.

### Owner posts and visitor posts

A post counts as written *on* the wall when it addresses the owner in `cc` or `audience` and comes
from someone else. Those get a "wrote on X's wall" ribbon above the card, and the **Wall posts**
filter narrows to exactly that set. No extra field is needed — the addressing already says it.

### Filters and grouping

The filter row counts each bucket up front and disables the empty ones, so you never tab into a
blank list. Entries are then bucketed into `Today`, `Yesterday`, and month headings. Both the
buckets and the relative timestamps are computed from the `now` prop rather than the wall clock, so
a server render and its hydration agree.

### Publishing and pagination

Composed posts are prepended immediately and kept until you refetch, so the wall never appears to
swallow a post in flight. When the collection has a `next` URL, a "Load earlier activity" button
passes it to `onLoadMore`.

### Structured data

The block emits one Schema.org `CollectionPage` wrapping an `ItemList` of postings. Only real posts
go in it — a "liked a post" row is not itself a posting, so it is left out rather than padding the
list.

Without a `collection` the block renders its own `wall-activities` fixtures, which cover every
activity type it knows how to draw.

