In-app discussion threads#

A chat-style clarification thread attached to any entity — built entirely on Palmyra form stores. No WebSocket needed; the UI polls (or refreshes on action) via the standard PalmyraStoreFactory.

Backend#

A simple message table + a Palmyra handler:

CREATE TABLE discussion (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  entity_type VARCHAR(32)  NOT NULL,
  entity_id   BIGINT       NOT NULL,
  sender      VARCHAR(128) NOT NULL,
  message     TEXT         NOT NULL,
  created_at  TIMESTAMP    NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_disc_entity ON discussion(entity_type, entity_id);
@PalmyraType(type = "Discussion", table = "discussion")
public class DiscussionModel {
    @PalmyraField(primaryKey = true) private Long id;
    @PalmyraField private String entityType;
    @PalmyraField private Long entityId;
    @PalmyraField private String sender;
    @PalmyraField private String message;
    @PalmyraField(drop = DropMode.INCOMING) private Instant createdAt;
}

@Component
@CrudMapping(mapping = "/discussion", type = DiscussionModel.class)
public class DiscussionHandler extends AbstractHandler
        implements QueryHandler, CreateHandler {

    @Override
    public Tuple preCreate(Tuple tuple, HandlerContext ctx) {
        tuple.setAttribute("sender", authProvider.getUser());
        tuple.setAttribute("createdAt", Instant.now());
        return tuple;
    }

    @Override
    public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) {
        filter.addOrderAsc("createdAt");
        return filter;
    }
}

React chat component#

// src/components/chat/DiscussionThread.tsx
import { useEffect, useRef, useState } from 'react';
import { Button, Group, Paper, ScrollArea, Stack, Text, Textarea } from '@mantine/core';
import AppStoreFactory from '../../wire/StoreFactory';

interface Props {
  entityType: string;
  entityId:   string | number;
  currentUser: string;
}

interface Message {
  id: number;
  sender: string;
  message: string;
  createdAt: string;
}

export default function DiscussionThread({ entityType, entityId, currentUser }: Props) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [draft, setDraft]       = useState('');
  const [sending, setSending]   = useState(false);
  const scrollRef               = useRef<HTMLDivElement>(null);

  const load = () => {
    const store = AppStoreFactory.getGridStore({}, '/discussion');
    store.query({
      filter: { entityType, entityId },
      sortOrder: { createdAt: 'asc' },
      limit: 200,
    }).then(r => {
      setMessages(r.result ?? []);
      setTimeout(() => scrollRef.current?.scrollTo({ top: 999999, behavior: 'smooth' }), 100);
    });
  };

  useEffect(load, [entityType, entityId]);

  const send = async () => {
    if (!draft.trim()) return;
    setSending(true);
    const store = AppStoreFactory.getFormStore({}, '/discussion');
    await store.post({ entityType, entityId, message: draft.trim() } as any);
    setDraft('');
    setSending(false);
    load();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
  };

  return (
    <Paper p="md" withBorder>
      <ScrollArea h={300} viewportRef={scrollRef}>
        <Stack gap="xs">
          {messages.map(m => (
            <div key={m.id}
                 style={{ alignSelf: m.sender === currentUser ? 'flex-end' : 'flex-start',
                          maxWidth: '70%' }}>
              <Paper p="xs" radius="md"
                     bg={m.sender === currentUser ? 'blue.1' : 'gray.1'}>
                <Text size="xs" fw={600} c="dimmed">{m.sender}</Text>
                <Text size="sm">{m.message}</Text>
                <Text size="xs" c="dimmed" ta="right">
                  {new Date(m.createdAt).toLocaleString()}
                </Text>
              </Paper>
            </div>
          ))}
        </Stack>
      </ScrollArea>
      <Group mt="sm" align="flex-end">
        <Textarea
          value={draft}
          onChange={e => setDraft(e.currentTarget.value)}
          onKeyDown={handleKeyDown}
          placeholder="Type a message… (Enter to send)"
          autosize minRows={1} maxRows={3}
          style={{ flex: 1 }}
        />
        <Button onClick={send} loading={sending}>Send</Button>
      </Group>
    </Paper>
  );
}

Using on a detail page#

<DiscussionThread
  entityType="Invoice"
  entityId={params.id!}
  currentUser={auth.getUser()}
/>

The same component works for any entity — just pass a different entityType / entityId.

Why no WebSocket?#

For internal tools and back-office apps, “refresh on send” gives near-real-time feel without the infrastructure cost of WebSocket servers. The user who sends a message sees it immediately (after the POST); other users see it on their next page visit or when they click a refresh button. If true real-time is needed, add a polling interval:

useEffect(() => {
  const interval = setInterval(load, 15000);   // poll every 15s
  return () => clearInterval(interval);
}, [entityType, entityId]);

See also: PalmyraStoreFactory, CreateHandler.