Push Notification
Last updated
Last updated
import { useEffect, useRef } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import * as Notifications from "expo-notifications";
import { registerPushToken, unregisterPushToken } from "@/lib/api/pushToken";
import { useAuth } from "@/lib/store/authStore";
import { logger } from "@/lib/utils/logger";
// Register expo token device token function
function getPushNotificationTokenAsync() {
if (Platform.OS === "android") {
Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
handleRegistrationError(
"Permission not granted to get push token for push notification!",
);
return;
}
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ??
Constants?.easConfig?.projectId;
if (!projectId) {
handleRegistrationError("Project ID not found");
}
try {
const { data: pushTokenString } =
await Notifications.getExpoPushTokenAsync({
projectId,
});
logger.debug("[PushNotification] Token:", pushTokenString);
return pushTokenString;
} catch (e: unknown) {
handleRegistrationError(`${e}`);
}
} else {
logger.debug(
"[PushNotification] Must use physical device for push notifications",
);
}
}
const useExpoPushToken = () => {
return useQuery({
queryKey: ["expoPushToken"],
queryFn: getPushNotificationTokenAsync,
staleTime: Infinity,
});
};
export function usePushNotification() {
const { user } = useAuth();
// obtain the expo token for the device
const { data: expoPushToken } = useExpoPushToken();
const { mutateAsync: registerPushTokenMutation } = useMutation({
mutationKey: ["registerPushToken"],
mutationFn: (token: string) => registerPushToken(token),
onSuccess: () => {
logger.debug("[PushNotification] Token registered with backend");
},
onError: (error) => {
logger.error("[PushNotification] Failed to register token:", error);
},
});
// unregister push token
const { mutateAsync: unregisterPushTokenMutation } = useMutation({
mutationKey: ["unregisterPushToken"],
mutationFn: (token: string) => unregisterPushToken(token),
onSuccess: () => {
logger.debug("[PushNotification] Token unregistered with backend");
},
onError: (error) => {
logger.error("[PushNotification] Failed to unregister token:", error);
},
});
const noificationListener = useRef<Notifications.EventSubscription>(undefined);
const responseListener = useRef<Notifications.EventSubscription>(undefined);
// Register for push notifications and send token to backend when user logs in
useEffect(() => {
if (!expoPushToken) return;
// User logged out, unregister token
if (!user?.id) {
unregisterPushTokenMutation(expoPushToken);
return;
}
// register new token and update to backend
registerPushTokenMutation(expoPushToken);
}, [
expoPushToken,
unregisterPushTokenMutation,
registerPushTokenMutation,
user?.id,
]);
function handleNotificationResponse(
response: Notifications.NotificationResponse,
) {
const data = response.notification.request.content.data as {
conversationId?: string;
messageId?: string;
type?: string;
};
logger.debug("[PushNotification] Notification response:", data);
if (data?.conversationId) {
// Navigate to the conversation
router.push(`/home/chat/conversation/${data.conversationId}`);
}
}
// Set up notification response listener
useEffect(() => {
// Listen for notification when app is running (in-app notification)
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
// in-app notification function
});
// Listen for notification when app is on in background or be killed
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
handleNotificationResponse(response);
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
}import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { OnEvent } from "@nestjs/event-emitter";
import Expo, { ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";
import { ConversationProfile } from "@/chat/entities/conversation-profile.entity";
import { Message, MessageType } from "@/chat/entities/message.entity";
import { MessageSentEvent } from "@/common/events/message-sent.event";
import { Profile } from "@/profile/entities/profile.entity";
import { ProfileDevice } from "@/profile-device/entities/profile-device.entity";
const MAX_NOTIFICATION_BODY_LENGTH = 100;
@Injectable()
export class PushNotificationService {
private readonly logger = new Logger(PushNotificationService.name);
private readonly expo: Expo;
constructor(
private readonly em: EntityManager,
private readonly configService: ConfigService,
) {
const accessToken = this.configService.get<string>("EXPO_ACCESS_TOKEN");
this.expo = new Expo({
accessToken,
});
}
/**
* Handle message sent event and send push notifications to recipients
*/
@OnEvent(MessageSentEvent.EVENT_NAME)
async handleMessageSent(event: MessageSentEvent): Promise<void> {
const { message } = event;
try {
await this.sendMessageNotification(message);
} catch (error) {
this.logger.error(
`Failed to send push notification for message ${message.id}`,
error instanceof Error ? error.stack : error,
);
}
}
/**
* Send push notification for a new message
*/
private async sendMessageNotification(message: Message): Promise<void> {
const forkedEm = this.em.fork();
const conversationId =
typeof message.conversation === "string"
? message.conversation
: message.conversation.id;
const senderProfileId =
typeof message.senderProfile === "string"
? message.senderProfile
: message.senderProfile.id;
// get back device of room member
const recipientTokens = await this.getRecipientDeviceTokens(
forkedEm,
conversationId,
senderProfileId,
);
if (recipientTokens.length === 0) {
this.logger.debug(
`No recipient device tokens found for conversation ${conversationId}`,
);
return;
}
// Construct the notification format
const senderProfile = await forkedEm.findOne(Profile, {
id: senderProfileId,
});
const senderName = senderProfile?.name ?? "Someone";
const notificationBody = this.buildNotificationBody(message);
const messages: ExpoPushMessage[] = recipientTokens
.filter((token) => Expo.isExpoPushToken(token))
.map((token) => ({
to: token,
sound: "default" as const,
title: senderName,
body: notificationBody,
data: {
conversationId,
messageId: String(message.id),
},
}));
if (messages.length === 0) {
this.logger.warn("No valid Expo push tokens found");
return;
}
await this.sendPushNotifications(messages);
}
/**
* Get device tokens for all conversation members except the sender
*/
private async getRecipientDeviceTokens(
em: EntityManager,
conversationId: string,
senderProfileId: string,
): Promise<string[]> {
const conversationProfiles = await em.find(ConversationProfile, {
conversation: { id: conversationId },
});
const recipientProfileIds = conversationProfiles
.map((cp) =>
typeof cp.profile === "string" ? cp.profile : cp.profile.id,
)
.filter((id) => id !== senderProfileId);
if (recipientProfileIds.length === 0) {
return [];
}
const devices = await em.find(ProfileDevice, {
profile: { $in: recipientProfileIds },
});
return devices.map((device) => device.expoDeviceToken);
}
/**
* Build notification body based on message type
*/
private buildNotificationBody(message: Message): string {
switch (message.type) {
case MessageType.TEXT:
return this.truncateText(message.content, MAX_NOTIFICATION_BODY_LENGTH);
case MessageType.IMAGE:
return "π· Sent an image";
case MessageType.VIDEO:
return "π₯ Sent a video";
case MessageType.FILE:
return "π Sent a file";
case MessageType.RECORDING:
return "π€ Sent a voice message";
default:
return "Sent a message";
}
}
/**
* Truncate text to specified length with ellipsis
*/
private truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
/**
* Send push notifications via Expo with proper chunking
*/
private async sendPushNotifications(
messages: ExpoPushMessage[],
): Promise<void> {
const chunks = this.expo.chunkPushNotifications(messages);
const tickets: ExpoPushTicket[] = [];
for (const chunk of chunks) {
try {
const ticketChunk = await this.expo.sendPushNotificationsAsync(chunk);
tickets.push(...ticketChunk);
} catch (error) {
this.logger.error("Error sending push notification chunk", error);
}
}
this.logTicketResults(tickets);
}
/**
* Log push notification ticket results for debugging
*/
private logTicketResults(tickets: ExpoPushTicket[]): void {
const successCount = tickets.filter((t) => t.status === "ok").length;
const errorCount = tickets.filter((t) => t.status === "error").length;
this.logger.debug(
`Push notifications sent: ${successCount} success, ${errorCount} errors`,
);
tickets.forEach((ticket, index) => {
if (ticket.status === "error") {
this.logger.warn(
`Ticket ${index} error: ${ticket.message} (${ticket.details?.error})`,
);
}
});
}
}