Conversations

Show a user their reports and your team's replies, and let them answer — with the React hooks or the client directly.

PanelUI components are native-only for now

The hooks and the client work on the web. The PanelUI components the App version draws threads with — Support, Message, Message Scroller and Textarea — are React Native components and don't run in a browser yet. A thread on the web builds the same screens with plain markup.

Every report is a thread. Your team replies from the inbox; the app reads the thread and can add to it. Replies travel over plain HTTP, so an open thread polls while someone is looking at it and nothing runs in the background.

A user's reports

useFeedbackList lists this install's own reports, newest first, with how many replies each has:

import { FlatList, RefreshControl } from 'react-native';
import { useFeedbackList } from 'panelui-studio/react';

export function MyReports({ onOpen }: { onOpen: (id: string) => void }) {
  const { items, isLoading, reload } = useFeedbackList();

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      refreshControl={<RefreshControl refreshing={isLoading} onRefresh={reload} />}
      renderItem={({ item }) => (
        <ReportRow report={item} onPress={() => onOpen(item.id)} />
      )}
    />
  );
}

It doesn't poll: a list of your own reports rarely changes while you look at it. Pull to refresh calls reload.

'use client';

import { useFeedbackList } from 'panelui-studio/react';

export function MyReports({ onOpen }: { onOpen: (id: string) => void }) {
  const { items, isLoading, reload } = useFeedbackList();

  return (
    <section>
      <button type="button" onClick={reload} disabled={isLoading}>
        Refresh
      </button>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <button type="button" onClick={() => onOpen(item.id)}>
              {item.message} {item.replies > 0 ? `· ${item.replies} replies` : ''}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

It doesn't poll: a list of your own reports rarely changes while you look at it. A refresh button, or opening the page again, calls reload.

One thread

useConversation loads a thread, polls it every 15 seconds while mounted, and sends replies:

import { useConversation } from 'panelui-studio/react';

export function Thread({ feedbackId }: { feedbackId: string }) {
  const { messages, isLoading, isSending, send } = useConversation(feedbackId);

  return (
    <>
      {messages.map((message) => (
        <Bubble key={message.id} mine={message.author === 'user'}>
          {message.body}
        </Bubble>
      ))}
      <Composer disabled={isSending} onSend={send} />
    </>
  );
}

Bubble and Composer stand in for your own components; Usage with PanelUI has the same thread built from PanelUI's.

Bubble and Composer stand in for your own components; A thread on the web has both in plain markup.

  • author is 'user' for the person using your app and 'developer' for your team. Internal notes never reach the app.
  • Pass { pollMs: 0 } to turn polling off, or a longer interval to poll less.
  • A sent message appears straight away; the next poll reconciles it.
  • Reading a thread marks your team's replies as read, which the inbox shows.
  • A reply on resolved feedback moves it back to In progress.

Without React

The client has the same operations:

const reports = await studio.feedback.list();
const thread = await studio.conversations.messages(reports[0].id);
await studio.conversations.send(reports[0].id, 'Yes, it happens every time.');

A thread on the web

The thread from above with the placeholders filled in: the messages in a list, and a form that sends a reply. It polls every 15 seconds while it's on screen.

components/thread.tsx
'use client';

import { useState } from 'react';
import { useConversation } from 'panelui-studio/react';

export function Thread({ feedbackId }: { feedbackId: string }) {
  const { messages, isSending, send } = useConversation(feedbackId);
  const [draft, setDraft] = useState('');

  async function reply(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = draft.trim();
    if (!body) return;
    setDraft('');
    await send(body);
  }

  return (
    <section aria-label="Conversation">
      <ol>
        {messages.map((message) => (
          <li key={message.id} data-author={message.author}>
            <strong>{message.author === 'user' ? 'You' : 'The team'}</strong>
            <p>{message.body}</p>
          </li>
        ))}
      </ol>
      <form onSubmit={reply}>
        <label htmlFor="reply">Reply</label>
        <textarea id="reply" value={draft} onChange={(event) => setDraft(event.target.value)} />
        <button type="submit" disabled={isSending}>
          Send
        </button>
      </form>
    </section>
  );
}

In Next.js, a route like app/messages/[id]/page.tsx can pass its id to <Thread feedbackId={id} />.

Usage with PanelUI

The hooks return plain data, so any UI can draw them. If your app uses PanelUI, here is a "My messages" screen: Support's conversation rows list the reports, with a status pill and a dot when there's a reply.

app/messages.tsx
import { router } from 'expo-router';
import { Support } from 'panelui-native';
import { useFeedbackList } from 'panelui-studio/react';

// PanelUI's `pending` reads "Waiting on you" — the user — so Studio's
// in_progress, which is the team's turn, stays `open`.
const status = { new: 'open', in_progress: 'open', resolved: 'solved', archived: 'closed' } as const;

export default function Messages() {
  const { items } = useFeedbackList();

  return (
    <Support.Conversations>
      {items.map((item) => (
        <Support.Conversation
          key={item.id}
          title={item.kind === 'support' ? 'Support' : `Feedback #${item.number}`}
          preview={item.message}
          status={status[item.status]}
          timestamp={new Date(item.lastMessageAt ?? item.createdAt).toLocaleDateString()}
          updated={item.replies > 0}
          onPress={() => router.push(`/thread/${item.id}`)}
        />
      ))}
    </Support.Conversations>
  );
}

The thread: Message draws each turn, Message Scroller keeps the newest in view, and a Textarea answers.

app/thread/[id].tsx
import { useState } from 'react';
import { KeyboardAvoidingView, View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { Button, Message, MessageScroller, Textarea } from 'panelui-native';
import { useConversation } from 'panelui-studio/react';

export default function Thread() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { messages, isSending, send } = useConversation(id);
  const [draft, setDraft] = useState('');

  async function reply() {
    const body = draft.trim();
    if (!body) return;
    setDraft('');
    await send(body);
  }

  return (
    <KeyboardAvoidingView behavior="padding" className="flex-1">
      <MessageScroller autoScroll className="flex-1">
        <MessageScroller.List
          data={messages.map((message) => ({ ...message, messageId: message.id }))}
          contentContainerClassName="gap-3 px-5"
          renderItem={({ item }) => (
            <Message align={item.author === 'user' ? 'end' : 'start'}>
              <Message.Content>
                <Message.Header>{item.author === 'user' ? 'You' : 'The team'}</Message.Header>
                <Message.Bubble>
                  <Message.BubbleContent>{item.body}</Message.BubbleContent>
                </Message.Bubble>
              </Message.Content>
            </Message>
          )}
        />
        <MessageScroller.Button />
      </MessageScroller>
      <View className="flex-row items-end gap-2 px-5 pt-3">
        <Textarea
          containerClassName="flex-1"
          autoGrow
          rows={1}
          maxRows={5}
          placeholder="Reply"
          value={draft}
          onChangeText={setDraft}
        />
        <Button loading={isSending} onPress={reply}>
          Send
        </Button>
      </View>
    </KeyboardAvoidingView>
  );
}

MessageScroller.List needs a stable messageId on each row, so the message id is copied onto it.

A conversation on an iPhone: the team's question on the left, and the user's answer on the right.
The team replied from the inbox; the app showed it on its next poll, and the user answered.

On this page