# WXM Realtime (Socket.IO) — Flutter Integration Guide

This document is everything the mobile (Flutter) team needs to integrate with the
WXM realtime gateway: connecting, authenticating, the events you can send/receive,
exact payload shapes, and copy-paste Flutter examples.

> **TL;DR**
> - Connect to **`wss://wxm.prompita.com`** with the **Socket.IO** client (Engine.IO v4).
> - Authenticate with the **same JWT** you get from `POST /api/v1/general/auth/login`, passed as `auth: { token }`.
> - **You SEND messages/comments/likes over the REST API. You RECEIVE them over the socket.** The socket is (almost) receive-only; the only things you emit are *control* events: `conversation.join`, `live.join`, `presence.watch`, etc.

---

## Table of contents
1. [Architecture in one picture](#1-architecture-in-one-picture)
2. [Connecting](#2-connecting)
3. [Authentication](#3-authentication)
4. [Connection lifecycle & errors](#4-connection-lifecycle--errors)
5. [Rooms model (important)](#5-rooms-model-important)
6. [Events you EMIT (client → server)](#6-events-you-emit-client--server)
7. [Events you RECEIVE (server → client)](#7-events-you-receive-server--client)
8. [Direct Messages (DM) — full flow](#8-direct-messages-dm--full-flow)
9. [Live streams — full flow](#9-live-streams--full-flow)
10. [Presence (online/offline)](#10-presence-onlineoffline)
11. [Support chat](#11-support-chat)
12. [Token refresh & reconnection](#12-token-refresh--reconnection)
13. [Complete Flutter example](#13-complete-flutter-example)
14. [Quick reference](#14-quick-reference)
15. [Gotchas / FAQ](#15-gotchas--faq)

---

## 1. Architecture in one picture

```
 Flutter app  ──(REST: send message)──────────────►  Laravel API (https://wxm.prompita.com)
     │                                                      │
     │                                                      │  Redis.publish("conversation.{id}", payload)
     │                                                      ▼
     │                                                   Redis (pub/sub)
     │                                                      │
     │  ◄──(Socket.IO: 'message' event)──  Node Socket.IO gateway (subscribes to Redis)
     └──────────────────────────────────────────────────────┘
```

- **Sending** anything (a DM, a live comment, a like, a gift) is a normal **HTTP REST call**.
- The server persists it, then **publishes** an event to Redis.
- The gateway relays that event to everyone in the relevant **room** as a Socket.IO event.
- So your realtime job in Flutter is: **connect, join the right rooms, and listen.**

---

## 2. Connecting

| Item | Value |
|------|-------|
| URL | `wss://wxm.prompita.com` (or `https://wxm.prompita.com`) |
| Protocol | Socket.IO **v4** / Engine.IO v4 |
| Path | `/socket.io/` (default — do **not** set a custom path) |
| Port | none — standard 443, behind the app's domain |
| Transport | WebSocket (polling fallback also works through the proxy) |

**Flutter package:** [`socket_io_client`](https://pub.dev/packages/socket_io_client) `^2.0.0`

```yaml
# pubspec.yaml
dependencies:
  socket_io_client: ^2.0.3
```

```dart
import 'package:socket_io_client/socket_io_client.dart' as IO;

final socket = IO.io(
  'https://wxm.prompita.com',
  IO.OptionBuilder()
      .setTransports(['websocket'])      // ['websocket','polling'] if behind a strict proxy
      .disableAutoConnect()              // we connect() manually after setting auth
      .setAuth({'token': jwtToken})      // <-- the JWT from login
      .enableReconnection()
      .setReconnectionAttempts(9999)
      .setReconnectionDelay(1000)        // ms
      .build(),
);

socket.connect();
```

> ⚠️ Use the **`https://` / `wss://`** scheme and let the client manage the upgrade.
> Do **not** point at `:6001` — that port is internal to the server only.

---

## 3. Authentication

The socket requires a **Laravel JWT** — the exact same access token you already use as
`Authorization: Bearer <token>` for the REST API. Get it from:

```
POST https://wxm.prompita.com/api/v1/general/auth/login
Headers:  user_type: supporter | streamer | client
          Accept-Language: ar | en
Body:     { "auth": "<phone>", "password": "<pass>", "auth_type": "phone", "phone_code": "966" }
→ data.token   ← this is the JWT
```

Pass the token in **any one** of these (pick the first one — it's the cleanest):

1. **Handshake auth (preferred):** `IO.OptionBuilder().setAuth({'token': jwt})`
2. Header: `Authorization: Bearer <jwt>` → `.setExtraHeaders({'Authorization': 'Bearer $jwt'})`
3. Query string: `?token=<jwt>`

### What the server checks
- The JWT signature & expiry (locally).
- A **remote validity check against Laravel** (honours logout / ban / token refresh). So if the
  user logs out or is banned, their socket is rejected/dropped even if the JWT hasn't expired yet.

### Auth error codes (on `connect_error`)
The error carries a `data.code` you can branch on:

| Code | Meaning | What to do |
|------|---------|-----------|
| `AUTH_MISSING` | No token in handshake | You forgot to set `auth.token` |
| `AUTH_EXPIRED` | JWT `exp` passed | **Refresh the token**, then reconnect |
| `AUTH_INVALID` | Malformed/invalid signature | Re-login |
| `AUTH_INVALID_SUB` | Token has no valid user id | Re-login |
| `AUTH_REVOKED` | Rejected by Laravel (logged out / banned / stale) | Re-login |
| `AUTH_FAILED` | Other verification failure | Re-login |

---

## 4. Connection lifecycle & errors

```dart
socket.onConnect((_) => print('connected: ${socket.id}'));
socket.onDisconnect((reason) => print('disconnected: $reason'));

socket.onConnectError((err) {
  // err is usually a Map like { "code": "AUTH_EXPIRED", "message": "JWT expired" }
  final code = (err is Map) ? err['code'] : null;
  print('connect_error: $code  ($err)');
  if (code == 'AUTH_EXPIRED') {
    // refresh token then reconnect — see section 12
  }
});

socket.onError((e) => print('socket error: $e'));
```

Reconnection is automatic (we enabled it in the OptionBuilder). On every successful
reconnect you should **re-join any rooms** you care about (conversations, lives,
presence) — room membership is per-connection and is lost on disconnect. See
[§5](#5-rooms-model-important) and the [full example](#13-complete-flutter-example).

---

## 5. Rooms model (important)

The gateway delivers events to **rooms**. Three things to know:

1. **Your personal room is automatic.** On connect you are auto-joined to `user.{yourId}`.
   This means you receive DMs addressed to you **even if you haven't opened that chat**
   (i.e. you didn't `conversation.join`). Use this to drive inbox badges / notifications.

2. **Conversation & live rooms are opt-in.** To get the live stream of events *while a
   screen is open*, you must `conversation.join` / `live.join`. Leave them when the screen
   closes (`conversation.leave` / `live.leave`) to stop receiving them.

3. **Rooms reset on reconnect.** After any reconnect, re-emit your joins.

> **DM de-duplication:** a DM is published to both the conversation room *and* the
> recipient's personal room. The gateway is smart enough **not** to send you the same
> message twice if you're in both. You may still want to de-dupe by `message.id` on the
> client as a safety net.

---

## 6. Events you EMIT (client → server)

All of these take an optional **acknowledgement callback** — always use it, it's how you
learn success/failure. Ack is either `{ ok: true, ... }` or `{ ok: false, code, message }`.

| Emit event | Payload | Ack (success) | Purpose |
|------------|---------|---------------|---------|
| `conversation.join` | `{ conversation_id: int }` | `{ ok: true }` | Receive live `message` events for this chat |
| `conversation.leave` | `{ conversation_id: int }` | `{ ok: true }` | Stop receiving them |
| `live.join` | `{ live_id: int }` | `{ ok: true }` | Receive `live` events for this stream |
| `live.leave` | `{ live_id: int }` | `{ ok: true }` | Stop receiving them |
| `presence.watch` | `{ user_ids: int[] }` (max 500) | `{ ok: true, states: { "12": true, "13": false } }` | Subscribe to online/offline + get current snapshot |
| `presence.unwatch` | `{ user_ids: int[] }` | `{ ok: true }` | Unsubscribe |
| `presence.query` | `{ user_ids: int[] }` (max 500) | `{ ok: true, states: {...} }` | One-shot online check, no subscription |

**Ack error codes:** `BAD_PAYLOAD` (bad/missing id), `AUTH_MISSING` (no token on socket),
`FORBIDDEN` (you're not a participant of that conversation), `INTERNAL` (server error).

```dart
socket.emitWithAck('conversation.join', {'conversation_id': 9}).then((ack) {
  if (ack['ok'] == true) {
    print('joined conversation 9');
  } else {
    print('join failed: ${ack['code']} ${ack['message']}');
  }
});
```

> `conversation.join` is **authorized**: the gateway asks Laravel whether you're really a
> participant. If you get `FORBIDDEN`, the logged-in user is not part of that conversation.

---

## 7. Events you RECEIVE (server → client)

| Listen event | Payload type | When |
|--------------|--------------|------|
| `message` | `MessageCreatedPayload` | A DM was sent in a conversation you're in (or addressed to you) |
| `support.message` | `SupportMessageCreatedPayload` | A support-chat message |
| `live` | `LiveEventPayload` | Any event in a live you've joined |
| `presence` | `PresenceUpdatePayload` | A user you're watching went online/offline |

### `message` — `MessageCreatedPayload`
```jsonc
{
  "event": "message.created",
  "conversation_id": 9,
  "message": {
    "id": 42,
    "sender_id": 12,
    "body": "مرحبا 👋",       // may be null if it's a media-only message
    "media_id": null,         // id of an attached media, or null
    "created_at": "2026-06-06T01:18:15.000000Z"
  }
}
```

### `support.message` — `SupportMessageCreatedPayload`
```jsonc
{
  "event": "support.message.created",
  "thread_id": 5,
  "user_id": 12,
  "message": {
    "id": 88,
    "sender_id": 12,
    "sender_role": "user",    // "user" | "admin"
    "body": "عندي مشكلة في الدفع",
    "media_id": null,
    "created_at": "2026-06-06T02:10:00.000000Z"
  }
}
```

### `presence` — `PresenceUpdatePayload`
```jsonc
{ "event": "user.online",  "user_id": 12 }   // or "user.offline"
```

### `live` — `LiveEventPayload`
Every live event has at least `event` and `live_id`, plus event-specific fields.
The `event` field is the discriminator:

| `event` | Extra fields (example) |
|---------|------------------------|
| `live.viewer_joined` | `user_id`, `current_viewers` |
| `live.viewer_left` | `user_id`, `current_viewers` |
| `live.comment` | `comment: { id, user_id, user_name, body, created_at }` |
| `live.like` | `user_id`, like totals |
| `live.gift` | gift + sender info, updated totals |
| `live.share` | `user_id` |
| `live.ended` | final stats |
| `live.viewer_blocked` | `user_id` |
| `live.comment_pinned` / `live.comment_unpinned` / `live.comment_removed` | `comment_id` |
| `live.guest_requested` / `live.guest_approved` / `live.guest_rejected` / `live.guest_kicked` / `live.guest_left` / `live.guest_updated` | guest/user info |
| `live.moderator_assigned` / `live.moderator_revoked` | `user_id` |

Example (`live.viewer_joined`):
```jsonc
{ "event": "live.viewer_joined", "live_id": 7, "user_id": 12, "current_viewers": 134 }
```
Example (`live.comment`):
```jsonc
{
  "event": "live.comment",
  "live_id": 7,
  "comment": { "id": 901, "user_id": 12, "user_name": "نورة", "body": "🔥🔥", "created_at": "2026-06-06T03:00:00Z" }
}
```

> Realtime is **best-effort**. The authoritative state (message history, viewer count,
> gift totals) is always re-fetchable over the REST API. If you miss an event (brief
> disconnect), just re-fetch on screen focus.

---

## 8. Direct Messages (DM) — full flow

**Sending is REST. Receiving is socket.**

1. **Open a chat screen** → `socket.emitWithAck('conversation.join', {conversation_id})`.
2. **Listen** for `message` events and append to the UI.
3. **Send a message** with the REST API (NOT the socket):
   ```
   POST https://wxm.prompita.com/api/v1/app/conversations/{id}/messages
   Authorization: Bearer <jwt>
   Accept-Language: ar
   Body: { "body": "نص الرسالة", "media_id": null }
   ```
   The server persists it and publishes it — your own socket (in the room) will also
   receive the `message` event, so you can render optimistically and reconcile by `id`.
4. **Close the chat** → `conversation.leave`.
5. Even without joining, you still get `message` events for chats addressed to you via your
   **personal room** — use that for inbox/unread badges.

Relevant REST endpoints:
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/v1/app/conversations` | List conversations |
| POST | `/api/v1/app/conversations/start` | Start/get a 1-1 conversation |
| GET | `/api/v1/app/conversations/{id}/messages` | Message history (paginated) |
| POST | `/api/v1/app/conversations/{id}/messages` | **Send** a message |
| POST | `/api/v1/app/conversations/{id}/read` | Mark read |
| POST | `/api/v1/app/conversations/{id}/mute` | Mute |

---

## 9. Live streams — full flow

Joining a live is **two steps**: an HTTP join (gets you the Agora RTC token + viewer
gating) and a socket join (gets you the realtime event feed).

1. **HTTP join** → `POST /api/v1/app/lives/{id}/join` → returns the **Agora RTC token**,
   channel, and current stats. (This enforces privacy/blocks.)
2. **Socket join** → `socket.emit('live.join', {live_id})` → start receiving `live` events.
3. **Listen** on `'live'` and switch on `payload.event` (see [§7](#7-events-you-receive-server--client)).
4. **Interactions** (comment / like / gift / request to co-host) are **REST calls**; the
   resulting events come back over the socket to everyone in the room.
5. **Leave** → `socket.emit('live.leave', {live_id})` and the HTTP leave endpoint.

> The socket only governs *which realtime events you receive*. RTC media + authorization is
> handled by the Agora token from the HTTP join.

---

## 10. Presence (online/offline)

```dart
// Subscribe to a set of users AND get their current state in the ack:
final ack = await socket.emitWithAck('presence.watch', {'user_ids': [12, 13, 99]});
// ack == { ok: true, states: { "12": true, "13": false, "99": true } }

// Then react to live transitions:
socket.on('presence', (data) {
  // { event: "user.online" | "user.offline", user_id: 12 }
});

// Stop watching when you leave the screen:
socket.emit('presence.unwatch', {'user_ids': [12, 13, 99]});

// One-shot check without subscribing:
final snap = await socket.emitWithAck('presence.query', {'user_ids': [12]});
```
Max **500** user ids per call.

---

## 11. Support chat

Same pattern as DM but on its own channel/events:
- Listen on **`support.message`** (payload in [§7](#7-events-you-receive-server--client)).
- The user receives messages on their personal support channel automatically once
  connected (no extra join needed for the user side).
- Send support messages via the support REST endpoints
  (`POST /api/v1/app/support/messages`).

---

## 12. Token refresh & reconnection

JWTs expire. When you get `connect_error` with code `AUTH_EXPIRED` (or proactively before
expiry), refresh and reconnect with the new token:

```dart
Future<void> refreshAndReconnect() async {
  final newJwt = await api.refreshToken();        // POST /api/v1/general/auth/token/refresh
  socket.auth = {'token': newJwt};                // update the handshake auth
  socket.disconnect();
  socket.connect();                               // reconnect with the fresh token
}
```

On `onConnect` (initial *and* every reconnect), **re-join your active rooms**:

```dart
socket.onConnect((_) {
  if (openConversationId != null) {
    socket.emit('conversation.join', {'conversation_id': openConversationId});
  }
  if (openLiveId != null) {
    socket.emit('live.join', {'live_id': openLiveId});
  }
  if (watchedUserIds.isNotEmpty) {
    socket.emit('presence.watch', {'user_ids': watchedUserIds});
  }
});
```

---

## 13. Complete Flutter example

A minimal, reusable service:

```dart
import 'package:socket_io_client/socket_io_client.dart' as IO;

class WxmRealtime {
  static const _url = 'https://wxm.prompita.com';
  late IO.Socket _socket;

  int? _openConversationId;
  final List<int> _watchedUsers = [];

  void connect(String jwt) {
    _socket = IO.io(
      _url,
      IO.OptionBuilder()
          .setTransports(['websocket'])
          .disableAutoConnect()
          .setAuth({'token': jwt})
          .enableReconnection()
          .setReconnectionAttempts(9999)
          .setReconnectionDelay(1000)
          .build(),
    );

    _socket.onConnect((_) {
      print('RT connected: ${_socket.id}');
      _rejoinRooms();                 // re-join after every (re)connect
    });

    _socket.onConnectError((err) {
      final code = (err is Map) ? err['code'] : null;
      print('RT connect_error: $code');
      if (code == 'AUTH_EXPIRED' || code == 'AUTH_REVOKED') {
        // refresh token then reconnect (see §12)
      }
    });

    _socket.onDisconnect((r) => print('RT disconnected: $r'));

    // ---- inbound events ----
    _socket.on('message', (data) {
      // MessageCreatedPayload — append to chat / bump unread badge
      print('DM: ${data['conversation_id']} -> ${data['message']}');
    });

    _socket.on('support.message', (data) {
      print('SUPPORT: $data');
    });

    _socket.on('live', (data) {
      switch (data['event']) {
        case 'live.comment': /* add comment */ break;
        case 'live.like': /* bump likes */ break;
        case 'live.viewer_joined': /* update count */ break;
        case 'live.ended': /* close screen */ break;
        // ... handle the rest
      }
    });

    _socket.on('presence', (data) {
      print('PRESENCE: user ${data['user_id']} is ${data['event']}');
    });

    _socket.connect();
  }

  // ---- DM screen ----
  Future<bool> openConversation(int id) async {
    _openConversationId = id;
    final ack = await _socket.emitWithAck('conversation.join', {'conversation_id': id});
    return ack['ok'] == true;
  }

  void closeConversation(int id) {
    _socket.emit('conversation.leave', {'conversation_id': id});
    if (_openConversationId == id) _openConversationId = null;
  }

  // ---- presence ----
  Future<Map> watchUsers(List<int> ids) async {
    _watchedUsers
      ..clear()
      ..addAll(ids);
    final ack = await _socket.emitWithAck('presence.watch', {'user_ids': ids});
    return ack['states'] ?? {};
  }

  void _rejoinRooms() {
    if (_openConversationId != null) {
      _socket.emit('conversation.join', {'conversation_id': _openConversationId});
    }
    if (_watchedUsers.isNotEmpty) {
      _socket.emit('presence.watch', {'user_ids': _watchedUsers});
    }
  }

  void updateToken(String jwt) {
    _socket.auth = {'token': jwt};
    _socket.disconnect();
    _socket.connect();
  }

  void dispose() => _socket.dispose();
}
```

> **Sending a DM** is *not* in this service — it's a normal REST call to
> `POST /api/v1/app/conversations/{id}/messages`. The reply arrives via the `message`
> listener above.

---

## 14. Quick reference

**Connect:** `wss://wxm.prompita.com`, Socket.IO v4, `auth: { token: <JWT> }`

**Emit (control):**
```
conversation.join   { conversation_id }      → { ok }
conversation.leave  { conversation_id }      → { ok }
live.join           { live_id }              → { ok }
live.leave          { live_id }              → { ok }
presence.watch      { user_ids: [..] }       → { ok, states }
presence.unwatch    { user_ids: [..] }       → { ok }
presence.query      { user_ids: [..] }       → { ok, states }
```

**Listen (inbound):**
```
message          → { event:"message.created",         conversation_id, message{...} }
support.message  → { event:"support.message.created", thread_id, user_id, message{...} }
live             → { event:"live.*",                  live_id, ... }
presence         → { event:"user.online|user.offline", user_id }
```

**Auth error codes:** `AUTH_MISSING, AUTH_EXPIRED, AUTH_INVALID, AUTH_INVALID_SUB, AUTH_REVOKED, AUTH_FAILED`
**Ack error codes:** `BAD_PAYLOAD, AUTH_MISSING, FORBIDDEN, INTERNAL`

---

## 15. Gotchas / FAQ

- **"I joined but no message arrives."** You only get `message` events for things sent
  *after* you joined. To show history, fetch `GET …/conversations/{id}/messages`. To test,
  send a message from the *other* participant via REST.
- **You don't send chat over the socket.** There is no `message.send` emit. Sending is REST.
- **`FORBIDDEN` on join** = the logged-in user isn't a participant of that conversation.
- **Re-join after reconnect.** Room membership is per-connection. Always re-emit joins in
  `onConnect`.
- **One socket per app.** Open a single shared connection for the whole app; don't open one
  per screen. Screens just join/leave rooms.
- **Token must match the environment.** A token minted on staging/local won't authenticate
  against production — always use a token from `wxm.prompita.com`.
- **Native apps aren't gated by CORS** (no `Origin` header), so CORS settings don't affect
  the Flutter app.
- **Timestamps** are ISO-8601 UTC strings (e.g. `2026-06-06T01:18:15.000000Z`).
- **De-dupe by `message.id`** if you render optimistically — your own sent message also
  comes back over the socket.

---

*Questions about the gateway internals or to report a payload mismatch: ping the backend team.
Server base: `https://wxm.prompita.com` · Realtime: `wss://wxm.prompita.com` (`/socket.io/`).*
