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.