All recipes

Recipe

Comment thread

A record's comments with their authors and a form to add one, kept current after each write. Web and native call useComments from better-supabase/blocks/comments/react.

The comments blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add button field skeleton textarea

Native: React Native

The snippet uses ActivityIndicator, Button, FlatList, Text, TextInput, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.

3.Install the glue

Install the block with npx better-supabase sql add comments. The glue items are copied into your app, so edit them as you need.

Web

None: the hook calls the block from the client.

Native

None: the hook calls the block from the client.

4.Write the component

Web: React and shadcn

View in the example
comment-thread.tsx
"use client";

import { useComments } from "better-supabase/blocks/comments/react";
import { useExtracted, useFormatter } from "next-intl";
import { type SubmitEvent, useId, useState } from "react";

import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

/** A customer's comments, oldest first, through the browser client. */
export function CommentThread({
  organizationId,
  customerId,
  authors,
  canComment,
}: {
  organizationId: string;
  customerId: string;
  /** Display names by user id. */
  authors: Readonly<Record<string, string>>;
  canComment: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();
  const errorMessage = useErrorMessage();
  const fieldId = useId();
  const [body, setBody] = useState("");
  const [pending, setPending] = useState(false);
  const { comments, error, add } = useComments({
    organizationId,
    subjectType: "customer",
    subjectId: customerId,
    schema: blockConfig.comments.api,
  });
  const visible = comments?.filter((comment) => !comment.deletedAt);

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    const text = body.trim();
    if (!text) return;
    setPending(true);
    if ((await add(text)).ok) setBody("");
    setPending(false);
  };

  return (
    <div className="space-y-6">
      {visible === undefined ? (
        <Skeleton className="h-16" />
      ) : visible.length === 0 ? (
        <p className="text-muted-foreground text-sm">{t("No comments yet.")}</p>
      ) : (
        <ol className="space-y-4" data-testid="comments">
          {visible.map((comment) => (
            <li key={comment.id} className="space-y-1">
              <p className="text-sm">
                <span className="font-medium">
                  {(comment.authorId && authors[comment.authorId]) ??
                    t("Someone")}
                </span>{" "}
                <span className="text-muted-foreground text-xs">
                  {format.dateTime(comment.createdAt.epochMilliseconds, {
                    dateStyle: "medium",
                    timeStyle: "short",
                  })}
                </span>
              </p>
              <p className="text-sm whitespace-pre-wrap">{comment.body}</p>
            </li>
          ))}
        </ol>
      )}
      {canComment ? (
        <form onSubmit={(event) => void submit(event)} className="space-y-3">
          <Field data-invalid={error ? true : undefined}>
            <FieldLabel htmlFor={`${fieldId}-comment-body`} className="sr-only">
              {t("Comment")}
            </FieldLabel>
            <Textarea
              id={`${fieldId}-comment-body`}
              value={body}
              onChange={(event) => {
                setBody(event.target.value);
              }}
              placeholder={t("Write a comment")}
              required
              maxLength={4000}
              rows={3}
            />
            {error ? <FieldError>{errorMessage(error)}</FieldError> : null}
          </Field>
          <Button type="submit" size="sm" disabled={pending}>
            {t("Comment")}
          </Button>
        </form>
      ) : null}
    </div>
  );
}

Native: React Native

View in the example
comment-thread.tsx
import { useComments } from "better-supabase/blocks/comments/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  FlatList,
  Text,
  TextInput,
  View,
} from "react-native";

import { API_SCHEMA } from "../lib/blocks";

/** A customer's comments, oldest first, with a box to add one. */
export function CommentThread({ customerId }: { readonly customerId: string }) {
  const organizationId = tenantOf(useAuth());
  const [body, setBody] = useState("");
  const [pending, setPending] = useState(false);
  const { comments, error, add } = useComments({
    organizationId,
    subjectType: "customer",
    subjectId: customerId,
    schema: API_SCHEMA,
  });

  const submit = async () => {
    const text = body.trim();
    if (!text) return;
    setPending(true);
    if ((await add(text)).ok) setBody("");
    setPending(false);
  };

  if (comments === undefined) return <ActivityIndicator />;
  return (
    <View style={{ flex: 1, gap: 12 }}>
      <FlatList
        data={comments.filter((comment) => !comment.deletedAt)}
        keyExtractor={(comment) => comment.id}
        ListEmptyComponent={<Text>No comments yet.</Text>}
        renderItem={({ item }) => (
          <View style={{ paddingVertical: 8 }}>
            <Text style={{ color: "#737373", fontSize: 12 }}>
              {item.createdAt.toLocaleString()}
            </Text>
            <Text>{item.body}</Text>
          </View>
        )}
      />
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={body}
        onChangeText={setBody}
        placeholder="Write a comment"
        multiline
        maxLength={4000}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8, minHeight: 64 }}
      />
      <Button
        title="Comment"
        disabled={pending || !body.trim()}
        onPress={() => void submit()}
      />
    </View>
  );
}