FeedFabric docsGitHub

Post cards and actions

Rendering a feed post (reposts, held posts, tags, photos), the actions on it, a comments thread, and the "Why this post?" panel built from ranking.factors.

Anatomy of a feed post

Field Render it as
repostOf (not null) A repost. Show “Reposted by @authorUsername” above the original (repostOf), and use the original for everything else on the card.
authorUsername, authorExternalUserId The byline. @username is public and @mention-able; externalUserId is what follow and favourite endpoints take.
content Text with #tags and @mentions linked (same patterns as the server, below). Keep line breaks (white-space: pre-line).
createdAt, editedAt Relative time; “edited” when editedAt is set.
isHeld Only ever true on the member’s own post. Say that only they can see it while it’s reviewed.
tags Chips linking to the tag feed. source is hashtag or inferred; you might show only hashtags in the text and inferred topics as subtle chips.
media Up to four photos. Show thumbnailUrl in the card and url when opened; width/height give the aspect ratio before they load. URLs expire after an hour.
ranking (For You only) Why this post?, and an “exploring” note when explored is true.

There are no counts or “liked by me” on feed posts (why); track the member’s own likes and reposts in the client.

Engagement state

// src/stores/engagement.ts: what this member did, so buttons show it. PUT/DELETE are idempotent,
// so an optimistic toggle that races another tab never doubles anything.
import { reactive } from 'vue'
import { api } from '../api'

const liked = reactive(new Set<string>())
const reposted = reactive(new Set<string>())

async function toggle(set: Set<string>, postId: string, on: () => Promise<void>, off: () => Promise<void>) {
  const was = set.has(postId)
  was ? set.delete(postId) : set.add(postId)
  try {
    await (was ? off() : on())
  } catch (e) {
    was ? set.add(postId) : set.delete(postId)   // put it back
    throw e
  }
}

export const engagement = {
  liked, reposted,
  toggleLike: (postId: string) => toggle(liked, postId, () => api.like(postId), () => api.unlike(postId)),
  toggleRepost: (postId: string) => toggle(reposted, postId, () => api.repost(postId), () => api.unrepost(postId)),
}

Linking tags and mentions

The server’s patterns (TextTokens.cs): a hashtag starts with a letter (#5k isn’t one), a mention is a username after @, and neither may follow a letter, digit or another #/@.

<!-- src/components/RichText.vue -->
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ text: string }>()
const pattern = /(?<![\w#@])(#[A-Za-z][A-Za-z0-9_]{1,49}|@[A-Za-z0-9][A-Za-z0-9_-]{0,49})/g

// split() with a capturing group alternates plain text (even indexes) and matches (odd ones). Rendered as text nodes
// and links, never as HTML.
const parts = computed(() => props.text.split(pattern).map((part, i) =>
  i % 2 === 0 ? { text: part }
  : part.startsWith('#') ? { tag: part.slice(1).toLowerCase(), text: part }
  : { user: part.slice(1).toLowerCase(), text: part }))
</script>

<template>
  <p class="text">
    <template v-for="(part, i) in parts" :key="i">
      <RouterLink v-if="'tag' in part" :to="`/tags/${part.tag}`">{{ part.text }}</RouterLink>
      <RouterLink v-else-if="'user' in part" :to="`/@${part.user}`">{{ part.text }}</RouterLink>
      <template v-else>{{ part.text }}</template>
    </template>
  </p>
</template>

<style scoped>
.text { margin: 0; white-space: pre-line; overflow-wrap: anywhere; }
</style>

The card

<!-- src/components/PostCard.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
  FabricAvatar, FabricBadge, FabricButton, FabricCard, FabricChip, FabricIcon, FabricMenu, FabricStack, useToast, type MenuAction,
} from '@feedfabric/fabric-ui'
import RichText from './RichText.vue'
import PostPhotos from './PostPhotos.vue'
import ReportDialog from './ReportDialog.vue'
import WhyThisPost from './WhyThisPost.vue'
import { timeAgo } from '../format'
import { useImpression } from '../composables/useImpression'
import { engagement } from '../stores/engagement'
import { api } from '../api'
import { describe } from '../api/errors'
import type { FeedPost } from '../types'

const props = defineProps<{ post: FeedPost; feed: 'following' | 'foryou' | 'other' }>()
const emit = defineEmits<{ remove: [id: string] }>()
const { toast } = useToast()

// A repost shows, and is acted on as, the post it shares
const shown = computed(() => props.post.repostOf ?? props.post)
const repostedBy = computed(() => (props.post.repostOf ? props.post.authorUsername : null))

// Reports an impression and dwell for the post the member actually sees (see "Reading signals")
const root = ref<InstanceType<typeof FabricCard>>()
useImpression(root, () => shown.value.id)

const liked = computed(() => engagement.liked.has(shown.value.id))
const reposted = computed(() => engagement.reposted.has(shown.value.id))
const run = (action: () => Promise<unknown>) => action().catch(e => toast({ title: describe(e), tone: 'bad' }))
const reportOpen = ref(false)

const menu = computed<MenuAction[]>(() => [
  ...(props.feed === 'foryou'
    ? [
        { label: 'Not interested', icon: 'eye-off' as const, onSelect: () => run(async () => {
          await api.notInterested(shown.value.id)
          emit('remove', props.post.id)
          toast({ title: "Got it. You'll see less like this." })
        }) },
        { label: 'Show more like this', icon: 'sparkle' as const, onSelect: () => run(async () => {
          await api.showMore(shown.value.id)
          toast({ title: "Got it. You'll see more like this.", tone: 'good' })
        }) },
      ]
    : []),
  { label: `Follow @${shown.value.authorUsername}`, icon: 'user' as const, onSelect: () => run(() => api.follow(shown.value.authorExternalUserId)) },
  { label: 'Add to favourites', icon: 'star' as const, onSelect: () => run(() => api.addFavourite(shown.value.authorExternalUserId)) },
  { label: 'Report', icon: 'flag' as const, tone: 'danger' as const, onSelect: () => { reportOpen.value = true } },
])
</script>

<template>
  <FabricCard ref="root" as="article" :aria-labelledby="`post-${post.id}-by`">
    <p v-if="repostedBy" class="context"><FabricIcon name="repeat" :size="14" /> Reposted by @{{ repostedBy }}</p>

    <header class="byline">
      <FabricAvatar :name="shown.authorUsername" decorative />
      <RouterLink :id="`post-${post.id}-by`" :to="`/@${shown.authorUsername}`">@{{ shown.authorUsername }}</RouterLink>
      <time :datetime="shown.createdAt">{{ timeAgo(shown.createdAt) }}</time>
      <span v-if="shown.editedAt" class="muted">· edited</span>
      <FabricMenu :items="menu" label="More actions on this post" class="more" />
    </header>

    <FabricBadge v-if="shown.isHeld" tone="warn">Only you can see this while it's reviewed</FabricBadge>
    <RichText :text="shown.content" />
    <PostPhotos v-if="shown.media.length" :photos="shown.media" :author="shown.authorUsername" />

    <FabricStack v-if="shown.tags.some(t => t.source === 'inferred')" direction="row" wrap :gap="1">
      <FabricChip v-for="t in shown.tags.filter(t => t.source === 'inferred')" :key="t.tag">{{ t.tag }}</FabricChip>
    </FabricStack>

    <FabricStack direction="row" :gap="1" class="actions">
      <FabricButton variant="ghost" size="sm" icon="heart" :aria-pressed="liked" @click="run(() => engagement.toggleLike(shown.id))">
        {{ liked ? 'Liked' : 'Like' }}
      </FabricButton>
      <FabricButton variant="ghost" size="sm" icon="message" :to="{ name: 'post', params: { id: shown.id } }">Comment</FabricButton>
      <FabricButton variant="ghost" size="sm" icon="repeat" :aria-pressed="reposted" :disabled="shown.isHeld"
        @click="run(() => engagement.toggleRepost(shown.id))">{{ reposted ? 'Reposted' : 'Repost' }}</FabricButton>
    </FabricStack>

    <WhyThisPost v-if="post.ranking" :ranking="post.ranking" />
    <ReportDialog v-model:open="reportOpen" :post-id="shown.id" />
  </FabricCard>
</template>

timeAgo is yours (a few lines of Intl.RelativeTimeFormat), as is ReportDialog: a FabricDialog with the seven reasons (spam, harassment, hate, threat, sexual, self_harm, other) that calls api.reportPost. Note ranking is read from the feed item (post.ranking), since For You never serves reposts; everything else from shown.

Photos

<!-- src/components/PostPhotos.vue -->
<script setup lang="ts">
import type { PostMedia } from '../types'
defineProps<{ photos: PostMedia[]; author: string }>()
</script>

<template>
  <div class="photos" :class="`n${photos.length}`">
    <a v-for="(photo, i) in photos" :key="photo.id" :href="photo.url" target="_blank" rel="noopener">
      <img :src="photo.thumbnailUrl" :width="photo.width" :height="photo.height" loading="lazy" decoding="async"
        :alt="`Photo ${i + 1} of ${photos.length} from @${author}`" />
    </a>
  </div>
</template>

<style scoped>
.photos { display: grid; gap: 4px; border-radius: var(--fabric-radius-md); overflow: hidden; }
.photos.n2, .photos.n4 { grid-template-columns: 1fr 1fr; }
.photos.n3 { grid-template-columns: 2fr 1fr; }
.photos.n3 a:first-child { grid-row: span 2; }
img { display: block; width: 100%; height: 100%; max-height: 520px; object-fit: cover; background: var(--fabric-sunken); }
.photos.n1 img { height: auto; aspect-ratio: auto; }
</style>

Posts carry no alt text, so describe the photo by position and author rather than leaving alt empty.

Comments

A post page renders the post you navigated from (there’s no GET for one post), reports an open, and loads its thread:

// src/views/PostView.vue (script): `post` comes from the store or router state, not a fetch
onMounted(() => {
  api.open(post.id).catch(() => {})        // the member opened the post: a ranking signal
  comments.refresh()
})
const comments = usePaged(cursor => api.comments(post.id, cursor))   // like useFeed, over { comments, nextCursor }

async function reply(content: string, parentCommentId: string | null) {
  const created = await api.comment(post.id, content, parentCommentId)   // 201 with the comment
  // Show it at once; if created.isHeld, label it "Only you can see this while it's reviewed"
}
  • Top-level comments come oldest first; each has a replyCount, and its replies load on demand with api.replies(commentId).
  • A comment with isDeleted: true has content: null: show “Comment deleted” so the replies under it still make sense.
  • content.violation comes back exactly as for posts; keep the text and say which rule.

Why this post?

Every For You post carries ranking.factors: the reasons, in words, each a multiplier on the score. 1 means no effect; above 1 pushed the post up, below 1 pulled it down. The labels you’ll see:

Label From
Recent, Your topics, Similar to what you like, This session, Author you like, You follow them, Popular, Trending now, Holds attention The built-in formula’s parts (shown whichever ranker scored, as the formula’s view of the post)
Profile formula The profile’s own formula; value is its score and detail the formula, rather than a multiplier
Newest first The newest profile
Freshness, Boosted, Lowered, Seen already Adjustments from the profile and the member’s history
<!-- src/components/WhyThisPost.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { FabricBadge, FabricDisclosure } from '@feedfabric/fabric-ui'
import type { PostRanking } from '../types'

const props = defineProps<{ ranking: PostRanking }>()
const formula = computed(() => props.ranking.factors.find(f => f.label === 'Profile formula'))
const factors = computed(() => props.ranking.factors.filter(f => f.label !== 'Profile formula'))
const ranker = computed(() => {
  const r = props.ranking.ranker
  if (r.startsWith('model:')) return 'the trained model'
  if (r.startsWith('expression')) return "this feed's own formula"
  return r === 'newest' ? 'newest first' : 'the scoring formula'
})
</script>

<template>
  <FabricDisclosure summary="Why this post?" :hint="`Ranked by ${ranker}`">
    <FabricBadge v-if="ranking.explored" tone="info">Exploring</FabricBadge>
    <p v-if="ranking.explored">Shown in a slot kept for posts the ranker wouldn't have picked, so it can learn what else you like.</p>
    <p v-if="formula"><code>{{ formula.detail }}</code> = {{ formula.value.toFixed(3) }}</p>
    <ul>
      <li v-for="f in factors" :key="f.label + f.detail">
        <strong>{{ f.label }}</strong> ×{{ f.value.toFixed(2) }}
        <span class="detail">{{ f.detail }}</span>
      </li>
    </ul>
  </FabricDisclosure>
</template>

Sort or trim the list as suits your design; a short “Because you follow @maya and it’s popular in #running” built from the two or three largest factors reads better on a phone than the full table.

Accessibility and phone width

  • Each card is an <article> labelled by its byline, so screen-reader users can move post by post.
  • Toggle buttons say their state with aria-pressed and a changing label (“Like” / “Liked”); icon-only buttons need FabricButton’s label.
  • Infinite scroll keeps a real Load more button, and aria-busy on the list while a page loads.
  • Toasts from useToast announce themselves; tone: 'bad' ones interrupt, the rest are polite.
  • Photos get descriptive alt text and fixed dimensions, so the page doesn’t jump as they load.
  • At phone width keep one column, put secondary actions in the FabricMenu, and give touch targets at least 40px. FabricAppShell folds the sidebar into a menu for you.
  • Respect prefers-reduced-motion; FabricUI’s own motion tokens drop to zero under it.
  • Don’t count an impression for a post that’s covered by a dialog or in a background tab (Reading signals).

Edit this page on GitHub

FeedFabric is MIT licensed and open source on GitHub. These pages are built fromweb/docs; the repository's own docs/ folder has the full configuration, data model and error lists. Found something wrong? Fix it oropen an issue.