Mobile App Integration
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:
- App calls
/_matrix/client/v3/loginto discover SSO redirect URL. - App opens Cognito login page in
expo-web-browser. - User authenticates with Cognito.
- Cognito redirects back to Synapse OIDC callback.
- Synapse issues a
loginTokenand redirects to the app’s deep link. - App exchanges
loginTokenfor a Matrix access token viam.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:
| Aspect | Convention |
|---|---|
| Theme hook | useAppTheme() for all colors and spacing |
| Heading font | fonts.headingBold from the theme |
| Body font | fonts.body from the theme |
| Terminology | Always “Phenom” (never “post” or “item”) |
| Timestamps | Relative (“2m ago”, “1h ago”) using the app’s existing date formatter |
| Avatars | Circular, 36px diameter, fallback to initials |
| Message bubbles | Use 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 };
}
Deep Link Registration
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
| Account | Password | Role | |
|---|---|---|---|
| Test User | test-user@thephenom.app | PhenomTest2026! | user |
| Test Admin | test-admin@thephenom.app | PhenomAdmin2026! | 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)
- Call
initiateMatrixSSOLogin()– verify the Cognito login page opens in the in-app browser. - Authenticate with a test account – verify the browser redirects back to the app with a
loginToken. - Call
completeMatrixLogin(loginToken)– verify you receive anaccess_token,user_id, anddevice_id. - Start the Matrix client and sync – verify the three rooms appear.
- 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)
- Obtain a Cognito ID token for a test account (use the app’s existing auth flow).
- Call
HasuraChatService.getMessageHistory(communityRoomId)– verify the query returns messages. - Call
HasuraChatService.sendMessage(communityRoomId, "test message")– verify the mutation succeeds. - Initialize the WebSocket subscription – verify the connection is established and new messages arrive in real time.
- Verify the subscription receives the message sent in step 3 (or a message from the Hasura Console).
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| SSO browser does not close | redirectUri does not match the app scheme | Verify phenomapp:// is registered in app.json |
401 Unauthorized on GraphQL | Expired Cognito token | Refresh the token using the app’s auth refresh flow |
| WebSocket disconnects immediately | Missing Authorization header in connectionParams | Ensure the token is passed in connectionParams.headers |
| No messages returned | Wrong room_id | Query chat_rooms to get the correct UUID |
| Link previews missing | Lambda not triggered | Check CloudWatch logs for /aws/lambda/phenom-dev-link-preview |
Related Documentation
- Chat Architecture – system design and database schema
- Chat API Reference – complete GraphQL and Matrix API docs
- Chat Admin & Operations – moderation and troubleshooting
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.