Skip to main content

Code Walkthrough

This walkthrough explains the code behind the terminal chat application, following the user flow from welcome screen to active chat.

Application Flow Overview

The application follows this sequence:

  1. Welcome Screen - Initial landing with options
  2. Enter Username - User inputs their display name
  3. Join Room - User enters a room name to join
  4. Chat View - Real-time chat interface powered by RelayX

1. Main Application Logic (app.tsx)

The main App component orchestrates the entire flow using React state management.

State Management

const [page, setPage] = useState("welcome");
const [currentRoom, setCurrentRoom] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);

Three key state variables control the application:

  • page: Tracks which screen to display (welcome, enter-username, join-room, chat, exit)
  • currentRoom: Stores the name of the room user has joined
  • username: Stores the user's chosen display name

Configuration Initialization

useEffect(() => {
initConfig();
}, []);

On app startup, initConfig() from config.ts creates a data/config.json file if it doesn't exist. This file stores RelayX API credentials.

Page Routing Logic

The App component uses conditional rendering to display different screens:

Welcome Page (source/app.tsx:23-41):

if(page == "welcome"){
return (
<ContainerElement>
<Select
options={[
{label: 'Join Room', value: 'join-room'},
{label: 'Exit', value: 'exit'}
]}
onChange={value => {
if (value === 'join-room') {
setPage('enter-username');
} else {
setPage(value);
}
}}
/>
</ContainerElement>
)
}

Shows a Select component with two options. When user selects "Join Room", it transitions to the username entry page.

Enter Username Page (source/app.tsx:42-59):

else if(page == "enter-username"){
return (
<Box flexDirection="column">
<ContainerElement />
<Box marginTop={1} flexDirection="column">
<Text>Enter your username</Text>
<TextInput
placeholder="Username"
onSubmit={(value) => {
if (value.trim()) {
setUsername(value.trim());
setPage('join-room');
}
}}
/>
</Box>
</Box>
)
}

Displays a TextInput for username entry. On submit, it validates the input (non-empty), stores it in state, and moves to the room selection page.

Join Room Page (source/app.tsx:60-69):

else if(page == "join-room"){
return (
<Box flexDirection="column">
<ContainerElement />
<JoinRoom onComplete={(roomName) => {
setCurrentRoom(roomName);
setPage("chat");
}} />
</Box>
)
}

Renders the JoinRoom component. When room selection completes, it stores the room name and transitions to chat.

Chat Page (source/app.tsx:70-79):

else if(page == "chat" && currentRoom && username){
return <ChatView
roomName={currentRoom}
username={username}
onExit={() => {
setPage('welcome');
setCurrentRoom(null);
setUsername(null);
}}
/>
}

Only renders if both currentRoom and username are set. Passes the room name, username, and an exit handler that resets the app state.

2. Welcome Screen Component (welcome.tsx)

The ContainerElement component provides consistent branding across all screens.

function ContainerElement({children}: {children?: any}) {
return (
<Box flexDirection="row" gap={5}>
<Box flexDirection="column" width={'70%'}>
<Gradient name="retro">
<BigText text="Relay" />
</Gradient>
<Text color="#e78618ff">A chat application powered by relayX Pub / Sub</Text>
<Text>More at relay-x.io</Text>
</Box>
{children && <Box justifyContent="center" flexDirection="column">
{children}
</Box>}
</Box>
);
}

Key Features:

  • Displays large "Relay" text with retro gradient
  • Shows application description
  • Accepts optional children prop to display page-specific content alongside branding
  • Uses Ink's flexbox layout with 70% width for branding, remaining space for content

3. Join Room Component (join-room.tsx)

Handles room name input with validation.

Validation Logic

const validateRoomName = (name: string): boolean => {
const regex = /^[a-z0-9]+$/;
return regex.test(name);
};

Room names must be lowercase alphanumeric only (a-z, 0-9). This ensures compatibility with RelayX channel naming conventions.

Real-time Validation (source/components/join-room.tsx:33-40)

const handleChange = (newValue: string) => {
if (newValue.length > 0 && !validateRoomName(newValue)) {
setError('Room name must be lowercase alphanumeric only (a-z, 0-9)');
} else {
setError(null);
}
};

Validates as the user types, providing immediate feedback if invalid characters are entered.

Submit Handler (source/components/join-room.tsx:18-31)

const handleSubmit = (roomName: string) => {
if (!roomName.trim()) {
setError('Room name cannot be empty');
return;
}

if (!validateRoomName(roomName)) {
setError('Room name must be lowercase alphanumeric only (a-z, 0-9)');
return;
}

setError(null);
onComplete(roomName);
};

Performs final validation on submit. If valid, calls the onComplete callback to notify the parent component.

4. Chat View Component (chat-view.tsx)

The core component that handles real-time messaging using RelayX.

State Management

const [messages, setMessages] = useState<Message[]>([]);
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'connecting' | 'disconnected' | 'reconnecting'>('connecting');
const [error, setError] = useState<string | null>(null);
const [inputKey, setInputKey] = useState(0);
const clientRef = useRef<any>(null);
  • messages: Array of chat messages
  • connectionStatus: Tracks WebSocket connection state (connecting, connected, reconnecting, disconnected)
  • error: Stores error messages for display
  • inputKey: Used to force TextInput remount (clear input after sending)
  • clientRef: Stores RelayX client instance

RelayX Connection Setup (source/components/chat-view.tsx:29-102)

useEffect(() => {
const connectToRelayX = async () => {
try {
const config = getConfig();

if (!config.api_key || !config.secret) {
setError('API credentials not configured...');
return;
}

const client = new Realtime({
api_key: config.api_key,
secret: config.secret
});

// Connection event handlers
await client.on(CONNECTED, (status: boolean) => {
if (status) {
setConnectionStatus('connected');
} else {
setError('Authentication failure...');
}
});

await client.on(RECONNECT, (status: string) => {
if (status === "RECONNECTING") {
setConnectionStatus('reconnecting');
} else if (status === "RECONNECTED") {
setConnectionStatus('connected');
}
});

await client.on(DISCONNECTED, () => {
setConnectionStatus('disconnected');
});

await client.init({});
await client.connect();

clientRef.current = client;

// Subscribe to room channel
await client.on(`chat.${roomName}`, (msg: any) => {
const newMessage: Message = {
id: `${msg.data.username}-${Date.now()}`,
username: msg.data.username,
text: msg.data.text,
timestamp: new Date(msg.data.timestamp).toLocaleTimeString()
};
setMessages(prev => [...prev, newMessage]);
});

} catch (err) {
setError(`Failed to connect: ${...}`);
}
};

connectToRelayX();

return () => {
if (clientRef.current) {
clientRef.current.close?.();
}
};
}, [roomName]);

Connection Flow:

  1. Load credentials from config.json
  2. Create RelayX client with API key and secret
  3. Set up event listeners for connection state changes:
    • CONNECTED: Handles connection event
    • RECONNECT: Handle reconnection attempts
    • DISCONNECTED: Connection lost
  4. Initialize and connect to RelayX servers
  5. Subscribe to room channel using pattern chat.{roomName}. In our case, chat.formula1
  6. Handle incoming messages by parsing and adding to state
  7. Cleanup on unmount by closing connection

Sending Messages (source/components/chat-view.tsx:104-129)

const handleSendMessage = async (text: string) => {
if (!text.trim() || !username) return;

// Special command to exit chat
if (text.trim() === '/close') {
if (clientRef.current) {
clientRef.current.close();
}
onExit();
return;
}

if (!clientRef.current) return;

try {
await clientRef.current.publish(`chat.${roomName}`, {
username: username,
text: text.trim(),
timestamp: new Date().toISOString()
});
// Clear input by forcing remount
setInputKey(prev => prev + 1);
} catch (err) {
setError(`Failed to send message: ${...}`);
}
};

Message Publishing:

  1. Validates message is not empty
  2. Checks for /close command to exit chat
  3. Uses client.publish() to send message to chat.{roomName} channel.
  4. Message payload includes username, text, and ISO timestamp
  5. Increments inputKey to force TextInput remount (clears input)

Message Display (source/components/chat-view.tsx:172-195)

<Box borderStyle="single" borderColor="gray" flexDirection="column" paddingX={1} paddingY={1} height={20}>
{messages.length === 0 ? (
<Text dimColor>No messages yet. Start the conversation!</Text>
) : (
messages.map(msg => {
const isMyMessage = msg.username === username;
return (
<Box key={msg.id} flexDirection="column" marginBottom={1}>
<Box justifyContent={isMyMessage ? 'flex-end' : 'flex-start'}>
<Box flexDirection="column">
<Box>
<Text bold color={isMyMessage ? 'cyan' : 'magenta'}>
{msg.username}
</Text>
<Text dimColor>{msg.timestamp}</Text>
</Box>
<Text>{msg.text}</Text>
</Box>
</Box>
</Box>
);
})
)}
</Box>

Messages are displayed in a scrollable box with:

  • Different colors for own messages (cyan) vs others (magenta)
  • Right-alignment for own messages, left for others
  • Username and timestamp for each message
  • Empty state when no messages exist

Connection Status Indicator (source/components/chat-view.tsx:141-149)

const getConnectionStatusText = () => {
switch (connectionStatus) {
case 'connected': return '● Connected';
case 'reconnecting': return '◐ Reconnecting...';
case 'connecting': return '◐ Connecting...';
case 'disconnected': return '○ Disconnected';
}
};

Visual indicator shows current connection state with appropriate symbols and colors.

5. Configuration Management (config.ts)

Handles API credential storage and retrieval.

Initialization (source/utils/config.ts:9-26)

export function initConfig(): void {
const dataDir = path.join(process.cwd(), 'data');
const configPath = path.join(dataDir, 'config.json');

if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}

if (!fs.existsSync(configPath)) {
const defaultConfig: Config = {
api_key: '',
secret: ''
};
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
}
}

Creates data/ directory and config.json with empty credentials if they don't exist.

Reading Configuration (source/utils/config.ts:28-42)

export function getConfig(): Config {
const dataDir = path.join(process.cwd(), 'data');
const configPath = path.join(dataDir, 'config.json');

try {
const data = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
return {
api_key: '',
secret: ''
};
}
}

Reads and parses config.json, returning empty credentials if file is missing or invalid.

Key Technologies

  • Ink: Terminal UI framework using React components
  • @inkjs/ui: Pre-built UI components (TextInput, Select)
  • relayx-js: RelayX SDK for real-time pub/sub messaging

Data Flow Summary

  1. User navigates through screens via state changes in App component
  2. Username and room name collected and validated
  3. ChatView establishes WebSocket connection to RelayX
  4. Messages published to chat.{roomName} channel via RelayX
  5. All clients subscribed to same channel receive messages in real-time
  6. UI updates reactively as messages arrive via React state