advanced messaging quick reference - nself-org/nchat GitHub Wiki
Version: nself-chat v0.3.0 For: Developers integrating advanced messaging features
import { useMessageMutations } from '@/hooks/use-messages'
function MyComponent() {
const {
updateMessage,
deleteMessage,
forwardMessage,
pinMessage,
starMessage,
markMessageRead,
startTyping,
stopTyping,
} = useMessageMutations()
// Use the functions...
}// User clicks "Edit" button
const handleEdit = async (messageId: string, newContent: string) => {
try {
await updateMessage(messageId, {
content: newContent,
mentions: extractMentions(newContent), // Helper function
})
// Success toast shown automatically
} catch (error) {
// Error toast shown automatically
}
}Database side-effect: Automatically records edit in nchat_message_edit_history via trigger.
// User clicks "Delete" button
const handleDelete = async (messageId: string) => {
const confirmed = await showConfirmDialog('Delete this message?')
if (!confirmed) return
try {
await deleteMessage(messageId, true) // true = soft delete
// Message content becomes "[deleted]"
// is_deleted flag set to true
} catch (error) {
// Error handled
}
}import { MessageForwardModal } from '@/components/chat/message-forward-modal'
function MyComponent() {
const [forwardModalOpen, setForwardModalOpen] = useState(false)
const [messageToForward, setMessageToForward] = useState(null)
const handleForward = async (
messages,
destinations,
mode,
comment
) => {
for (const dest of destinations) {
await forwardMessage({
messageId: messages[0].id,
targetChannelId: dest.id,
content: formatForwardedMessage(messages[0], mode, comment),
})
}
}
return (
<>
<Button onClick={() => {
setMessageToForward(message)
setForwardModalOpen(true)
}}>
Forward
</Button>
<MessageForwardModal
isOpen={forwardModalOpen}
onClose={() => setForwardModalOpen(false)}
messages={[messageToForward]}
availableDestinations={channels}
onForward={handleForward}
/>
</>
)
}// User clicks "Pin" button (moderators only)
const handlePin = async (messageId: string, channelId: string) => {
try {
await pinMessage(messageId, channelId)
// Success toast: "Message pinned to channel"
} catch (error) {
// Error toast shown
}
}
// Unpin
const handleUnpin = async (messageId: string, channelId: string) => {
await unpinMessage(messageId, channelId)
// Success toast: "Message unpinned"
}// User clicks "Bookmark" button
const handleStar = async (messageId: string) => {
try {
await starMessage(messageId)
// No toast (silent operation)
// Update UI to show filled star icon
} catch (error) {
// Error logged
}
}
// Unstar
const handleUnstar = async (messageId: string) => {
await unstarMessage(messageId)
// Update UI to show empty star icon
}// When message enters viewport
import { useInView } from 'react-intersection-observer'
function MessageItem({ message }) {
const { ref, inView } = useInView({ threshold: 0.5 })
const { markMessageRead } = useMessageMutations()
useEffect(() => {
if (inView && !message.isRead) {
markMessageRead(message.id)
}
}, [inView, message.id, message.isRead])
return <div ref={ref}>{/* message content */}</div>
}function MessageInput({ channelId }) {
const { startTyping, stopTyping } = useMessageMutations()
const [content, setContent] = useState('')
const typingTimeoutRef = useRef(null)
const handleTyping = (e) => {
setContent(e.target.value)
// Start typing indicator
startTyping(channelId)
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Auto-stop after 3 seconds
typingTimeoutRef.current = setTimeout(() => {
stopTyping(channelId)
}, 3000)
}
const handleBlur = () => {
stopTyping(channelId)
}
return (
<input
value={content}
onChange={handleTyping}
onBlur={handleBlur}
/>
)
}import { MessageEditHistory } from '@/components/chat/message-edit-history'
import { useQuery } from '@apollo/client'
import { GET_MESSAGE_EDIT_HISTORY } from '@/graphql/queries/messages'
function MessageWithHistory({ message }) {
const [historyOpen, setHistoryOpen] = useState(false)
const { data, loading, error } = useQuery(GET_MESSAGE_EDIT_HISTORY, {
variables: { messageId: message.id },
skip: !historyOpen,
})
return (
<>
{message.isEdited && (
<button
onClick={() => setHistoryOpen(true)}
className="text-xs text-muted-foreground"
>
(edited)
</button>
)}
<MessageEditHistory
open={historyOpen}
onOpenChange={setHistoryOpen}
history={data?.message_edit_history || []}
currentContent={message.content}
author={message.user}
isLoading={loading}
error={error?.message}
/>
</>
)
}import { getMessagePermissions } from '@/components/chat/message-actions'
function MessageItem({ message, currentUser }) {
const isOwnMessage = message.userId === currentUser.id
const permissions = getMessagePermissions(isOwnMessage, currentUser.role)
return (
<div>
{permissions.canEdit && <EditButton />}
{permissions.canDelete && <DeleteButton />}
{permissions.canPin && <PinButton />}
{permissions.canForward && <ForwardButton />}
{permissions.canReact && <ReactionPicker />}
</div>
)
}Permissions object:
{
canEdit: boolean // Own messages only
canDelete: boolean // Own or moderator+
canPin: boolean // Moderator+ only
canReact: boolean // Non-guests
canReply: boolean // Non-guests
canThread: boolean // Non-guests
canBookmark: boolean // Non-guests (use starMessage)
canForward: boolean // Non-guests
canReport: boolean // Others' messages, non-guests
canCopy: boolean // Everyone
canMarkUnread: boolean // Non-guests
}import { useQuery } from '@apollo/client'
import { gql } from '@apollo/client'
const GET_MESSAGE_DETAIL = gql`
query GetMessageDetail($messageId: uuid!) {
nchat_message_by_pk(id: $messageId) {
id
content
is_edited
edit_count
last_edited_at
is_deleted
deleted_at
deleted_by
forwarded_from
user {
id
display_name
avatar_url
}
edit_history: message_edit_history(order_by: { edited_at: desc }) {
id
old_content
new_content
edited_at
user {
id
display_name
avatar_url
}
}
starred_by: starred_messages {
user_id
starred_at
note
}
read_by: message_read_receipts {
user_id
read_at
user {
id
display_name
avatar_url
}
}
pinned_in: pinned_messages {
channel_id
pinned_by
pinned_at
}
}
}
`
function MessageDetail({ messageId }) {
const { data, loading } = useQuery(GET_MESSAGE_DETAIL, {
variables: { messageId },
})
if (loading) return <Spinner />
const message = data.nchat_message_by_pk
return (
<div>
<MessageContent content={message.content} />
{message.is_edited && (
<EditBadge count={message.edit_count} />
)}
{message.starred_by.length > 0 && (
<StarCount count={message.starred_by.length} />
)}
{message.read_by.length > 0 && (
<ReadReceipts users={message.read_by} />
)}
</div>
)
}SELECT * FROM nchat.get_user_starred_messages(
'user-id-here'::uuid,
50, -- limit
0 -- offset
);SELECT * FROM nchat.get_message_edit_history(
'message-id-here'::uuid
);SELECT nchat.can_edit_message(
'user-id-here'::uuid,
'message-id-here'::uuid
);import { useSubscription } from '@apollo/client'
const MESSAGE_UPDATED_SUBSCRIPTION = gql`
subscription MessageUpdated($messageId: uuid!) {
nchat_message_by_pk(id: $messageId) {
id
content
is_edited
is_deleted
edit_count
last_edited_at
deleted_at
}
}
`
function LiveMessage({ messageId }) {
const { data } = useSubscription(MESSAGE_UPDATED_SUBSCRIPTION, {
variables: { messageId },
})
const message = data?.nchat_message_by_pk
return (
<div>
{message.is_deleted ? (
<DeletedPlaceholder />
) : (
<>
<MessageContent content={message.content} />
{message.is_edited && <EditBadge />}
</>
)}
</div>
)
}function MessageItem({ message }) {
return (
<div className="group relative rounded-lg p-3 hover:bg-muted/50">
{/* Message content */}
<MessageContent content={message.content} />
{/* Edit indicator */}
{message.isEdited && (
<span className="ml-1 text-xs text-muted-foreground">
(edited)
</span>
)}
{/* Hover actions */}
<div className="absolute right-2 top-2 hidden group-hover:block">
<MessageActions messageId={message.id} />
</div>
</div>
)
}function MessageItem({ message }) {
if (message.isDeleted) {
return (
<div className="rounded-lg p-3 opacity-60">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Trash2 className="h-4 w-4" />
<span className="italic">Message deleted</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDate(message.deletedAt)}
</div>
</div>
)
}
return <div>{/* Normal message */}</div>
}All mutations automatically:
- ✅ Show toast notifications on success/error
- ✅ Log errors to console (dev) and Sentry (prod)
- ✅ Return error objects for custom handling
const { updateMessage } = useMessageMutations()
try {
await updateMessage(messageId, { content: newContent })
// Success toast shown automatically
} catch (error) {
// Error toast shown automatically
// Optional: Custom error handling
if (error.message.includes('permission')) {
showPermissionDeniedDialog()
}
}import { renderHook, act } from '@testing-library/react'
import { useMessageMutations } from '@/hooks/use-messages'
describe('useMessageMutations', () => {
it('should update message', async () => {
const { result } = renderHook(() => useMessageMutations())
await act(async () => {
await result.current.updateMessage('msg-123', {
content: 'Updated content',
})
})
expect(result.current.updatingMessage).toBe(false)
// Verify mutation was called
})
it('should handle edit permissions', async () => {
// Mock user without edit permission
const { result } = renderHook(() => useMessageMutations())
await expect(
result.current.updateMessage('other-user-message', {
content: 'Hacked',
})
).rejects.toThrow('Permission denied')
})
})// e2e/advanced-messaging.spec.ts
import { test, expect } from '@playwright/test'
test('should edit and view message history', async ({ page }) => {
await page.goto('/chat/general')
// Send a message
await page.fill('[data-testid="message-input"]', 'Original message')
await page.click('[data-testid="send-button"]')
// Edit the message
await page.hover('[data-testid="message-1"]')
await page.click('[data-testid="edit-button"]')
await page.fill('[data-testid="edit-input"]', 'Edited message')
await page.click('[data-testid="save-edit"]')
// Verify edit indicator
await expect(page.locator('text="(edited)"')).toBeVisible()
// View history
await page.click('text="(edited)"')
await expect(page.locator('[data-testid="edit-history-modal"]')).toBeVisible()
await expect(page.locator('text="Original message"')).toBeVisible()
await expect(page.locator('text="Edited message"')).toBeVisible()
})import { useDebouncedCallback } from 'use-debounce'
const debouncedStartTyping = useDebouncedCallback(
(channelId) => startTyping(channelId),
500 // Wait 500ms before sending
)// Instead of marking each message individually
const markChannelRead = async (channelId: string, lastMessageId: string) => {
// Use channel-level read receipt
await updateChannelReadReceipt(channelId, lastMessageId)
}import { useVirtualizer } from '@tanstack/react-virtual'
function MessageList({ messages }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<MessageItem
key={messages[virtualRow.index].id}
message={messages[virtualRow.index]}
/>
))}
</div>
)
}Check:
- Is
is_editedflag true? - Does
nchat_message_edit_historytable have rows? - Is the trigger
record_message_editactive?
-- Check trigger exists
SELECT * FROM pg_trigger WHERE tgname = 'message_edit_history_trigger';
-- Check edit history
SELECT * FROM nchat.nchat_message_edit_history
WHERE message_id = 'your-message-id';Check:
- Is WebSocket connected?
- Is
nchat_typing_indicatortable being updated? - Has the indicator expired (TTL)?
-- Check active typing indicators
SELECT * FROM nchat.nchat_typing_indicator
WHERE channel_id = 'your-channel-id'
AND expires_at > NOW();Check:
- Is user authenticated?
- Is
nchat_message_read_receipttable receiving inserts? - Are permissions set correctly in Hasura?
-- Check read receipts
SELECT * FROM nchat.nchat_message_read_receipt
WHERE message_id = 'your-message-id';-
Full Implementation Doc:
/Users/admin/Sites/nself-chat/docs/advanced-messaging-implementation-summary.md -
Database Migration:
/Users/admin/Sites/nself-chat/.backend/migrations/012_advanced_messaging_features.sql -
GraphQL Mutations:
/Users/admin/Sites/nself-chat/src/graphql/mutations/messages.ts -
React Hook:
/Users/admin/Sites/nself-chat/src/hooks/use-messages.ts -
UI Components:
/Users/admin/Sites/nself-chat/src/components/chat/
Last Updated: January 30, 2026 Version: v0.3.0