Mobile App Integration

Integration guide for adding Phenom Chat to the React Native mobile app, covering both Matrix/Synapse and Hasura Lite implementations.

This guide is written for Jon and any future mobile developers working on the PhenomApp React Native/Expo/TypeScript codebase. It covers both chat backend implementations so the app can switch between them for A/B testing.

Prerequisites

  • PhenomApp React Native project (Expo managed workflow)
  • TypeScript strict mode enabled
  • Access to chat-testing.thephenom.app
  • Test accounts (see Testing section below)

Shared Configuration

chatConfig.ts

Create a centralized configuration file that both implementations reference.

// src/config/chatConfig.ts

export interface ChatEnvironment {
  synapse: {
    homeserverUrl: string;
    serverName: string;
  };
  hasura: {
    httpEndpoint: string;
    wsEndpoint: string;
  };
  cognito: {
    userPoolId: string;
    region: string;
  };
  rooms: {
    internal: string;
    partners: string;
    community: string;
  };
}

const environments: Record<string, ChatEnvironment> = {
  testing: {
    synapse: {
      homeserverUrl: "https://chat-testing.thephenom.app",
      serverName: "chat-testing.thephenom.app",
    },
    hasura: {
      httpEndpoint: "https://chat-testing.thephenom.app/api/graphql",
      wsEndpoint: "wss://chat-testing.thephenom.app/api/graphql",
    },
    cognito: {
      userPoolId: "us-east-1_XXXXXXXXX", // from Terraform output
      region: "us-east-1",
    },
    rooms: {
      internal: "phenom-internal-room-id",
      partners: "phenom-partners-room-id",
      community: "phenom-community-room-id",
    },
  },
  production: {
    synapse: {
      homeserverUrl: "https://chat.thephenom.app",
      serverName: "chat.thephenom.app",
    },
    hasura: {
      httpEndpoint: "https://chat.thephenom.app/api/graphql",
      wsEndpoint: "wss://chat.thephenom.app/api/graphql",
    },
    cognito: {
      userPoolId: "us-east-1_XXXXXXXXX",
      region: "us-east-1",
    },
    rooms: {
      internal: "production-internal-room-id",
      partners: "production-partners-room-id",
      community: "production-community-room-id",
    },
  },
};

export const chatConfig: ChatEnvironment =
  environments[__DEV__ ? "testing" : "production"];

Implementation A: Matrix/Synapse

Implementation A uses the Matrix protocol via Synapse. Authentication is handled through Cognito OIDC SSO – the user taps “Login”, is redirected to Cognito in an in-app browser, and returns with a Matrix loginToken.

Dependencies

npx expo install matrix-js-sdk expo-web-browser

SSO Auth Flow

The auth flow works as follows:

  1. App calls /_matrix/client/v3/login to discover SSO redirect URL.
  2. App opens Cognito login page in expo-web-browser.
  3. User authenticates with Cognito.
  4. Cognito redirects back to Synapse OIDC callback.
  5. Synapse issues a loginToken and redirects to the app’s deep link.
  6. App exchanges loginToken for a Matrix access token via m.login.token.
// src/services/chat/matrixAuth.ts

import * as WebBrowser from "expo-web-browser";
import { chatConfig } from "@/config/chatConfig";

const { homeserverUrl } = chatConfig.synapse;

/**
 * Initiate Matrix SSO login via Cognito.
 * Opens an in-app browser that redirects through Cognito and back.
 */
export async function initiateMatrixSSOLogin(): Promise<string> {
  // The redirect URI the app will handle via deep linking
  const redirectUri = "phenomapp://chat/sso-callback";

  // Build the SSO redirect URL
  const ssoUrl =
    `${homeserverUrl}/_matrix/client/v3/login/sso/redirect/cognito` +
    `?redirectUrl=${encodeURIComponent(redirectUri)}`;

  // Open in-app browser -- user authenticates with Cognito
  const result = await WebBrowser.openAuthSessionAsync(ssoUrl, redirectUri);

  if (result.type !== "success" || !result.url) {
    throw new Error("SSO login was cancelled or failed");
  }

  // Extract loginToken from the callback URL
  const url = new URL(result.url);
  const loginToken = url.searchParams.get("loginToken");

  if (!loginToken) {
    throw new Error("No loginToken received from Synapse SSO");
  }

  return loginToken;
}

/**
 * Exchange a loginToken for a full Matrix session (access_token).
 */
export async function completeMatrixLogin(
  loginToken: string
): Promise<{
  accessToken: string;
  userId: string;
  deviceId: string;
}> {
  const response = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      type: "m.login.token",
      token: loginToken,
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Matrix login failed: ${error}`);
  }

  const data = await response.json();

  return {
    accessToken: data.access_token,
    userId: data.user_id,
    deviceId: data.device_id,
  };
}

Connecting to a Room with matrix-js-sdk

// src/services/chat/matrixClient.ts

import { createClient, MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { chatConfig } from "@/config/chatConfig";

let matrixClient: MatrixClient | null = null;

export function initMatrixClient(
  accessToken: string,
  userId: string,
  deviceId: string
): MatrixClient {
  matrixClient = createClient({
    baseUrl: chatConfig.synapse.homeserverUrl,
    accessToken,
    userId,
    deviceId,
  });

  return matrixClient;
}

export async function startMatrixSync(): Promise<void> {
  if (!matrixClient) throw new Error("Matrix client not initialized");

  await matrixClient.startClient({ initialSyncLimit: 50 });

  // Wait for the initial sync to complete
  await new Promise<void>((resolve) => {
    matrixClient!.once("sync" as any, (state: string) => {
      if (state === "PREPARED") resolve();
    });
  });
}

export function getMatrixClient(): MatrixClient {
  if (!matrixClient) throw new Error("Matrix client not initialized");
  return matrixClient;
}

Mapping Matrix Events to GiftedChat IMessage

// src/services/chat/matrixMessageMapper.ts

import { MatrixEvent } from "matrix-js-sdk";
import { IMessage } from "react-native-gifted-chat";

/**
 * Convert a Matrix m.room.message event to a GiftedChat IMessage.
 */
export function matrixEventToIMessage(event: MatrixEvent): IMessage | null {
  if (event.getType() !== "m.room.message") return null;

  const content = event.getContent();
  if (!content.body) return null;

  const sender = event.getSender();
  const timestamp = event.getTs();

  return {
    _id: event.getId() ?? `msg_${timestamp}`,
    text: content.body,
    createdAt: new Date(timestamp),
    user: {
      _id: sender ?? "unknown",
      name: sender?.split(":")[0]?.replace("@", "") ?? "Unknown",
    },
  };
}

/**
 * Listen for new messages in a Matrix room and call the handler.
 */
export function onMatrixRoomMessage(
  client: import("matrix-js-sdk").MatrixClient,
  roomId: string,
  handler: (message: IMessage) => void
): () => void {
  const listener = (event: MatrixEvent) => {
    if (event.getRoomId() !== roomId) return;

    const message = matrixEventToIMessage(event);
    if (message) handler(message);
  };

  client.on("Room.timeline" as any, listener);

  // Return cleanup function
  return () => {
    client.removeListener("Room.timeline" as any, listener);
  };
}

Implementation B: Hasura Lite

Implementation B uses GraphQL queries, mutations, and subscriptions against the Hasura GraphQL Engine. It follows the IAPIAdapter pattern used elsewhere in PhenomApp.

Dependencies

npx expo install graphql graphql-ws

HasuraChatService

// src/services/chat/HasuraChatService.ts

import { chatConfig } from "@/config/chatConfig";

interface ChatMessage {
  id: string;
  content: string;
  message_type: "text" | "phenom_link";
  phenom_id?: string;
  created_at: string;
  is_deleted: boolean;
  user: {
    id: string;
    username: string;
    avatar_url?: string;
  };
}

export class HasuraChatService {
  private httpEndpoint: string;
  private token: string;

  constructor(cognitoIdToken: string) {
    this.httpEndpoint = chatConfig.hasura.httpEndpoint;
    this.token = cognitoIdToken;
  }

  private async query<T>(
    gql: string,
    variables?: Record<string, unknown>
  ): Promise<T> {
    const response = await fetch(this.httpEndpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify({ query: gql, variables }),
    });

    const json = await response.json();
    if (json.errors) {
      throw new Error(json.errors.map((e: any) => e.message).join("; "));
    }
    return json.data as T;
  }

  /**
   * Fetch message history with cursor-based pagination.
   */
  async getMessageHistory(
    roomId: string,
    limit = 50,
    before?: string
  ): Promise<ChatMessage[]> {
    const data = await this.query<{ chat_messages: ChatMessage[] }>(
      GET_MESSAGE_HISTORY,
      { roomId, limit, before: before ?? new Date().toISOString() }
    );
    return data.chat_messages;
  }

  /**
   * Send a text message to a room.
   */
  async sendMessage(
    roomId: string,
    content: string
  ): Promise<ChatMessage> {
    const data = await this.query<{
      insert_chat_messages_one: ChatMessage;
    }>(SEND_MESSAGE, { roomId, content });
    return data.insert_chat_messages_one;
  }
}

GraphQL Operations

# GetMessageHistory -- cursor-based pagination
query GetMessageHistory($roomId: uuid!, $limit: Int!, $before: timestamptz!) {
  chat_messages(
    where: {
      room_id: { _eq: $roomId }
      is_deleted: { _eq: false }
      created_at: { _lt: $before }
    }
    order_by: { created_at: desc }
    limit: $limit
  ) {
    id
    content
    message_type
    phenom_id
    created_at
    is_deleted
    user {
      id
      username
      avatar_url
    }
  }
}
# SendMessage
mutation SendMessage($roomId: uuid!, $content: String!) {
  insert_chat_messages_one(
    object: { room_id: $roomId, content: $content }
  ) {
    id
    content
    message_type
    created_at
    user {
      id
      username
    }
  }
}
# OnNewMessages -- real-time subscription
subscription OnNewMessages($roomId: uuid!, $since: timestamptz!) {
  chat_messages(
    where: {
      room_id: { _eq: $roomId }
      is_deleted: { _eq: false }
      created_at: { _gt: $since }
    }
    order_by: { created_at: asc }
  ) {
    id
    content
    message_type
    phenom_id
    created_at
    user {
      id
      username
      avatar_url
    }
  }
}

WebSocket Subscription Setup

// src/services/chat/chatSubscription.ts

import { createClient as createWsClient, Client } from "graphql-ws";
import { chatConfig } from "@/config/chatConfig";

let wsClient: Client | null = null;

/**
 * Initialize the GraphQL WebSocket client with Cognito JWT auth.
 */
export function initChatSubscription(cognitoIdToken: string): Client {
  wsClient = createWsClient({
    url: chatConfig.hasura.wsEndpoint,
    connectionParams: {
      headers: {
        Authorization: `Bearer ${cognitoIdToken}`,
      },
    },
    retryAttempts: 5,
    shouldRetry: () => true,
    on: {
      connected: () => console.log("[chat-ws] Connected"),
      closed: () => console.log("[chat-ws] Disconnected"),
      error: (err) => console.error("[chat-ws] Error:", err),
    },
  });

  return wsClient;
}

/**
 * Subscribe to new messages in a room.
 * Returns an unsubscribe function.
 */
export function subscribeToMessages(
  roomId: string,
  since: string,
  onMessage: (message: any) => void
): () => void {
  if (!wsClient) throw new Error("WebSocket client not initialized");

  const unsubscribe = wsClient.subscribe(
    {
      query: `
        subscription OnNewMessages($roomId: uuid!, $since: timestamptz!) {
          chat_messages(
            where: {
              room_id: { _eq: $roomId }
              is_deleted: { _eq: false }
              created_at: { _gt: $since }
            }
            order_by: { created_at: asc }
          ) {
            id
            content
            message_type
            phenom_id
            created_at
            user { id username avatar_url }
          }
        }
      `,
      variables: { roomId, since },
    },
    {
      next: (data) => {
        const messages = (data.data as any)?.chat_messages ?? [];
        messages.forEach(onMessage);
      },
      error: (err) => console.error("[chat-sub] Error:", err),
      complete: () => console.log("[chat-sub] Complete"),
    }
  );

  return unsubscribe;
}

Shared Components

PhenomLinkPreview Component

When a message contains a Phenom content link (message_type: "phenom_link"), render it as a rich preview card instead of plain text.

// src/components/chat/PhenomLinkPreview.tsx

import React from "react";
import { View, Text, Image, TouchableOpacity, Linking } from "react-native";
import { useAppTheme } from "@/hooks/useAppTheme";

export interface PhenomLinkPreviewData {
  url: string;
  phenomId: string;
  title?: string;
  description?: string;
  thumbnailUrl?: string;
  mediaType?: string;
}

interface PhenomLinkPreviewProps {
  preview: PhenomLinkPreviewData;
  onPress?: (url: string) => void;
}

export const PhenomLinkPreview: React.FC<PhenomLinkPreviewProps> = ({
  preview,
  onPress,
}) => {
  const { colors, fonts } = useAppTheme();

  const handlePress = () => {
    if (onPress) {
      onPress(preview.url);
    } else {
      Linking.openURL(preview.url);
    }
  };

  return (
    <TouchableOpacity
      onPress={handlePress}
      style={{
        backgroundColor: colors.surfaceVariant,
        borderRadius: 12,
        overflow: "hidden",
        marginVertical: 4,
      }}
    >
      {preview.thumbnailUrl && (
        <Image
          source={{ uri: preview.thumbnailUrl }}
          style={{ width: "100%", height: 160 }}
          resizeMode="cover"
        />
      )}
      <View style={{ padding: 12 }}>
        {preview.title && (
          <Text
            style={{
              fontFamily: fonts.headingBold,
              fontSize: 15,
              color: colors.text,
              marginBottom: 4,
            }}
          >
            {preview.title}
          </Text>
        )}
        {preview.description && (
          <Text
            style={{
              fontFamily: fonts.body,
              fontSize: 13,
              color: colors.textSecondary,
            }}
            numberOfLines={2}
          >
            {preview.description}
          </Text>
        )}
      </View>
    </TouchableOpacity>
  );
};

Style Guide

Follow these conventions to keep the chat UI consistent with the rest of PhenomApp:

AspectConvention
Theme hookuseAppTheme() for all colors and spacing
Heading fontfonts.headingBold from the theme
Body fontfonts.body from the theme
TerminologyAlways “Phenom” (never “post” or “item”)
TimestampsRelative (“2m ago”, “1h ago”) using the app’s existing date formatter
AvatarsCircular, 36px diameter, fallback to initials
Message bubblesUse colors.primaryContainer for own messages, colors.surfaceVariant for others

Developer Settings Toggle

Add a toggle in the developer settings screen to switch between implementations at runtime. This enables side-by-side comparison during A/B testing.

// In your developer settings screen component:

import { useState, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";

type ChatBackend = "matrix" | "hasura";

export function useChatBackendToggle() {
  const [backend, setBackend] = useState<ChatBackend>("hasura");

  const toggleBackend = useCallback(async () => {
    const next: ChatBackend = backend === "hasura" ? "matrix" : "hasura";
    await AsyncStorage.setItem("chat_backend", next);
    setBackend(next);
  }, [backend]);

  return { backend, toggleBackend };
}

Register the SSO callback deep link in app.json so Synapse can redirect back to the app after Cognito authentication.

{
  "expo": {
    "scheme": "phenomapp",
    "plugins": [
      [
        "expo-web-browser"
      ]
    ]
  }
}

The deep link phenomapp://chat/sso-callback?loginToken=... will be handled by the app’s navigation system.


Testing

Test Accounts

AccountEmailPasswordRole
Test Usertest-user@thephenom.appPhenomTest2026!user
Test Admintest-admin@thephenom.appPhenomAdmin2026!admin

These accounts exist in the Cognito User Pool and are pre-provisioned as chat_members in all three rooms.

Verification Steps – Implementation A (Matrix/Synapse)

  1. Call initiateMatrixSSOLogin() – verify the Cognito login page opens in the in-app browser.
  2. Authenticate with a test account – verify the browser redirects back to the app with a loginToken.
  3. Call completeMatrixLogin(loginToken) – verify you receive an access_token, user_id, and device_id.
  4. Start the Matrix client and sync – verify the three rooms appear.
  5. Send a message – verify it appears in the Synapse Admin UI at https://chat-testing.thephenom.app/chat-admin.

Verification Steps – Implementation B (Hasura Lite)

  1. Obtain a Cognito ID token for a test account (use the app’s existing auth flow).
  2. Call HasuraChatService.getMessageHistory(communityRoomId) – verify the query returns messages.
  3. Call HasuraChatService.sendMessage(communityRoomId, "test message") – verify the mutation succeeds.
  4. Initialize the WebSocket subscription – verify the connection is established and new messages arrive in real time.
  5. Verify the subscription receives the message sent in step 3 (or a message from the Hasura Console).

Common Issues

IssueCauseFix
SSO browser does not closeredirectUri does not match the app schemeVerify phenomapp:// is registered in app.json
401 Unauthorized on GraphQLExpired Cognito tokenRefresh the token using the app’s auth refresh flow
WebSocket disconnects immediatelyMissing Authorization header in connectionParamsEnsure the token is passed in connectionParams.headers
No messages returnedWrong room_idQuery chat_rooms to get the correct UUID
Link previews missingLambda not triggeredCheck CloudWatch logs for /aws/lambda/phenom-dev-link-preview