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.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add button field skeleton textareaNative: 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"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 exampleimport { 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>
);
}