Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/Commands/ChatWebSocketServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,15 @@ public function run(array $params): void

$loop = Factory::create();
$socket = new SocketServer('0.0.0.0:' . $port, [], $loop);
$chatServer = new ChatServer();
$loop->addPeriodicTimer(1.0, static function () use ($chatServer): void {
$chatServer->pruneInactiveTypers();
});

$server = new IoServer(
new HttpServer(
new WsServer(
new ChatServer()
$chatServer
)
),
$socket,
Expand Down
124 changes: 121 additions & 3 deletions app/Libraries/ChatWebSocketServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
*/
class ChatWebSocketServer implements MessageComponentInterface
{
private const TYPING_TTL_SECONDS = 5;

/**
* Connected clients storage
*
Expand Down Expand Up @@ -68,6 +70,13 @@ class ChatWebSocketServer implements MessageComponentInterface
*/
protected bool $requireAuth;

/**
* Ephemeral typing state keyed by authenticated user ID.
*
* @var array<int, array{user_id: int, username: string, last_typed_at: int}>
*/
private array $typingUsers = [];

/**
* Constructor
*
Expand Down Expand Up @@ -160,23 +169,30 @@ public function onMessage(ConnectionInterface $from, $msg)
$data = json_decode($msg, true);

// Validate message format
if (!$data || !isset($data['action'])) {
if (! is_array($data) || (! isset($data['action']) && ! isset($data['type']))) {
return;
}

// Get the user info stored with this connection
$connectionData = $this->clients[$from] ?? [];
$userId = $connectionData['user_id'] ?? 0;

// Handle different actions
switch ($data['action']) {
// Typing events use `type`; existing chat operations keep `action`.
$messageType = $data['type'] ?? $data['action'];

switch ($messageType) {
case 'getMessages':
$this->handleGetMessages($from, $data);
break;

case 'sendMessage':
$this->handleSendMessage($from, $data, $userId);
break;

case 'typing_start':
case 'typing_stop':
$this->handleTypingEvent($from, $data, $messageType);
break;
}
}

Expand All @@ -199,6 +215,11 @@ public function onClose(ConnectionInterface $conn): void
// Remove the connection from our storage
$this->clients->detach($conn);

if (is_int($userId) && isset($this->typingUsers[$userId])) {
unset($this->typingUsers[$userId]);
$this->broadcastTypingState();
}

echo 'Connection ' . spl_object_id($conn) . " (user_id: {$userId}) has disconnected\n";
}

Expand Down Expand Up @@ -250,6 +271,31 @@ public function isUserConnected(int $userId): bool
return false;
}

/**
* Remove typing entries that have not been refreshed recently.
*
* The React event loop calls this once per second. The optional timestamp
* keeps the expiry behavior deterministic in tests.
*/
public function pruneInactiveTypers(?int $now = null): void
{
$now ??= time();
$changed = false;

foreach ($this->typingUsers as $userId => $typingUser) {
if ($now - $typingUser['last_typed_at'] < self::TYPING_TTL_SECONDS) {
continue;
}

unset($this->typingUsers[$userId]);
$changed = true;
}

if ($changed) {
$this->broadcastTypingState();
}
}

/**
* Log server startup message
*
Expand Down Expand Up @@ -469,6 +515,78 @@ private function sendSearchError(ConnectionInterface $from, string $message, ?in
]));
}

/** @param array<string, mixed> $data */
private function handleTypingEvent(ConnectionInterface $from, array $data, string $messageType): void
{
if (! $this->clients->contains($from)) {
return;
}

$connectionData = $this->clients[$from];
$authenticatedUserId = $connectionData['user_id'] ?? 0;
$claimedUserId = $data['user_id'] ?? null;
$username = $data['username'] ?? null;

if (
($connectionData['authenticated'] ?? false) !== true
|| ! is_int($authenticatedUserId)
|| $authenticatedUserId <= 0
|| ! is_int($claimedUserId)
|| $claimedUserId !== $authenticatedUserId
|| ! is_string($username)
|| trim($username) === ''
|| mb_strlen($username) > 255
) {
return;
}

if ($messageType === 'typing_stop') {
if (! isset($this->typingUsers[$authenticatedUserId])) {
return;
}

unset($this->typingUsers[$authenticatedUserId]);
$this->broadcastTypingState($from);

return;
}

$username = trim($username);
$changed = ! isset($this->typingUsers[$authenticatedUserId])
|| $this->typingUsers[$authenticatedUserId]['username'] !== $username;
$this->typingUsers[$authenticatedUserId] = [
'user_id' => $authenticatedUserId,
'username' => $username,
'last_typed_at' => time(),
];

if ($changed) {
$this->broadcastTypingState($from);
}
}

private function broadcastTypingState(?ConnectionInterface $except = null): void
{
$users = array_values(array_map(
static fn (array $typingUser): array => [
'user_id' => $typingUser['user_id'],
'username' => $typingUser['username'],
],
$this->typingUsers,
));

usort($users, static fn (array $left, array $right): int => [$left['username'], $left['user_id']] <=> [$right['username'], $right['user_id']]);
$payload = json_encode(['type' => 'typing_state', 'users' => $users]);

foreach ($this->clients as $client) {
if ($except !== null && $client === $except) {
continue;
}

$client->send($payload);
}
}

/**
* Handle the sendMessage action
*
Expand Down
46 changes: 44 additions & 2 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ The Vue client chooses `ws:` vs `wss:` based on the page protocol and uses port

### Protocol (messages)

All WebSocket messages are JSON with an `action` field.
All WebSocket messages are JSON. Message-history events use an `action` field,
while ephemeral typing events use a `type` field.

#### Client → server: `getMessages`

Expand Down Expand Up @@ -512,6 +513,44 @@ backward compatible:
}
```

#### Client → server: typing events

The Vue and Svelte clients announce typing activity with the authenticated
user's numeric ID and username:

```json
{ "type": "typing_start", "user_id": 42, "username": "alice" }
```

```json
{ "type": "typing_stop", "user_id": 42, "username": "alice" }
```

The server ignores typing events from unauthenticated connections and events
whose `user_id` does not match the authenticated connection. Repeated
`typing_start` events refresh the user's activity without producing duplicate
state broadcasts.

#### Server → client: `typing_state`

When the set of active typers changes, the server sends the complete current
state to other connected clients:

```json
{
"type": "typing_state",
"users": [
{ "user_id": 42, "username": "alice" },
{ "user_id": 57, "username": "bob" }
]
}
```

Typing state is ephemeral and is not stored in the database. A user is removed
when the client sends `typing_stop`, the connection closes, or no
`typing_start` refresh arrives for five seconds. The client that originated a
start or stop event is excluded from that event's broadcast.

### Browser example

```js
Expand Down Expand Up @@ -579,13 +618,16 @@ The views load bundles using `vite_tags()` from `app/Helpers/vite_helper.php`.
- **Realtime**:
- Connects to WebSocket on port 8080 for realtime updates
- Falls back to HTTP (`/api/v1/messages`) if WebSocket is not connected
- Sends debounced typing activity and renders other active users in an
accessible live status region

### Svelte — `src/svelte/main.js` + `src/svelte/App.svelte`

- **Page**: `/chat/svelte` (`app/Views/chat/svelteView.php`)
- **API**: reads and posts through `/api/v1/messages`
- **Realtime**: uses the same WebSocket protocol as the Vue client and falls
back to the versioned HTTP API
back to the versioned HTTP API, including debounced typing activity and an
accessible live typing indicator

---

Expand Down
16 changes: 15 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This user guide provides comprehensive instructions for using the CodeIgniter Ch
- [Searching Messages](#searching-messages)
- [Loading Older Messages](#loading-older-messages)
- [Real-time Updates](#real-time-updates)
- [Typing Indicators](#typing-indicators)
5. [Troubleshooting](#troubleshooting)
6. [FAQ](#faq)

Expand Down Expand Up @@ -114,6 +115,7 @@ The Vue.js chat interface uses the Vue.js framework for a modern, reactive user
- Message formatting with Markdown
- Search by message text, username, and date range
- Lazy loading of older messages
- Live typing indicators
- Timestamps and user information

**Example Usage**:
Expand All @@ -135,6 +137,7 @@ The Svelte chat interface provides the same modern chat workflow as the Vue.js i
- Message formatting with Markdown
- Search by message text, username, and date range
- Lazy loading of older messages
- Live typing indicators
- Timestamps and user information

## User Account Management
Expand Down Expand Up @@ -211,12 +214,23 @@ By default, the chat shows the most recent messages. To load older messages:

### Real-time Updates

The Vue.js chat interface uses WebSockets for real-time communication. This means:
The Vue.js and Svelte chat interfaces use WebSockets for real-time communication. This means:

- New messages from other users appear instantly
- No need to refresh the page
- Efficient use of network resources

### Typing Indicators

While another user is entering a message in the Vue.js or Svelte chat, their
name appears above the message composer. The indicator clears when they stop
typing, leave the input, send the message, disconnect, or remain inactive for
five seconds. Your own name is never shown in your typing indicator.

The animated dots are disabled automatically when your device or browser has
reduced-motion preferences enabled; the text status remains available to screen
readers.

## Troubleshooting

### Messages Not Appearing
Expand Down
50 changes: 49 additions & 1 deletion e2e/support/chat-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function chatScenario(scenario: ChatScenario): void {
await expect(page.locator(scenario.messageInput)).toBeVisible();
if (isModernChat) {
await assertModernChatAccessibility(page, scenario.messageInput);
await assertModernChatTyping(page, scenario.messageInput);
await assertModernChatSearch(page);
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');
await page.context().setOffline(true);
Expand All @@ -68,6 +69,46 @@ export function chatScenario(scenario: ChatScenario): void {
});
}

async function assertModernChatTyping(page: Page, messageInput: string): Promise<void> {
await page.evaluate(() => {
window.__chatTypingRequests = [];
});

const input = page.locator(messageInput);
await input.fill('Draft in progress');

await expect
.poll(async () =>
page.evaluate(
() => window.__chatTypingRequests?.filter((request) => request.type === 'typing_start').length ?? 0,
),
)
.toBe(1);
const typingStart = await page.evaluate(() =>
window.__chatTypingRequests?.find((request) => request.type === 'typing_start'),
);
expect(typingStart).toEqual({ type: 'typing_start', user_id: 1, username: 'e2euser' });
await expect(page.locator('.typing-indicator')).toContainText('Teammate is typing');
await expect(page.locator('.typing-indicator')).not.toContainText('e2euser');

await page.emulateMedia({ reducedMotion: 'reduce' });
await expect(page.locator('.typing-dots span').first()).toHaveCSS('animation-name', 'none');
await page.emulateMedia({ reducedMotion: 'no-preference' });

await input.blur();
await expect
.poll(async () =>
page.evaluate(() => window.__chatTypingRequests?.filter((request) => request.type === 'typing_stop').length ?? 0),
)
.toBe(1);
const typingStop = await page.evaluate(() =>
window.__chatTypingRequests?.find((request) => request.type === 'typing_stop'),
);
expect(typingStop).toEqual({ type: 'typing_stop', user_id: 1, username: 'e2euser' });
await expect(page.locator('.typing-indicator')).toBeHidden();
await input.fill('');
}

async function assertModernChatAccessibility(page: Page, messageInput: string): Promise<void> {
await expect(page.getByRole('main', { name: 'CodeIgniter Chat' })).toBeVisible();

Expand Down Expand Up @@ -184,6 +225,7 @@ async function assertModernChatSearch(page: Page): Promise<void> {
async function installModernChatWebSocket(page: Page): Promise<void> {
await page.addInitScript(() => {
window.__chatSearchRequests = [];
window.__chatTypingRequests = [];
window.__expectedSearchDates = {
from: Math.floor(new Date(2026, 8, 13, 0, 0, 0, 0).getTime() / 1000),
to: Math.floor(new Date(2026, 8, 14, 23, 59, 59, 999).getTime() / 1000),
Expand All @@ -200,7 +242,13 @@ async function installModernChatWebSocket(page: Page): Promise<void> {
const request = JSON.parse(rawMessage);
let response = null;

if (request.action === 'getMessages' && request.search) {
if (request.type === 'typing_start' || request.type === 'typing_stop') {
window.__chatTypingRequests.push(request);
response = {
type: 'typing_state',
users: request.type === 'typing_start' ? [{ user_id: 999, username: 'Teammate' }] : [],
};
} else if (request.action === 'getMessages' && request.search) {
window.__chatSearchRequests.push(request);
const page = request.page || 1;
response =
Expand Down
Loading
Loading