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
23 changes: 15 additions & 8 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ const config: ExpoConfig = {
tsconfigPaths: true,
},
plugins: [
// Generates the shared `targets/pegada-widgets` WidgetKit extension
// target (home-screen widgets, Live Activities, Control Center controls)
// at prebuild time. iOS allows one widget extension per app, so every
// widget-family feature registers in PegadaWidgetsBundle.swift instead
// of adding a target. Team ID comes from EAS credentials at build time;
// local sim builds don't sign.
// Wires every directory under `targets/` into the Xcode project at
// prebuild time: the shared `pegada-widgets` WidgetKit extension
// (home-screen widgets, Live Activities, Control Center controls) and the
// `notification-service` extension that restyles chat pushes as iOS
// communication notifications. iOS allows one widget extension per app, so
// every widget-family feature registers in PegadaWidgetsBundle.swift
// instead of adding a target. Team ID comes from EAS credentials at build
// time; local sim builds don't sign.
"@bacons/apple-targets",
"expo-secure-store",
"expo-notifications",
Expand Down Expand Up @@ -314,10 +316,15 @@ const config: ExpoConfig = {
usesNonExemptEncryption: false,
},
bundleIdentifier: "app.pegada",
// Shared storage between the app and the widget extension: the matches
// snapshot (UserDefaults) + downloaded avatars (container files).
entitlements: {
// Shared storage between the app and the widget extension: the matches
// snapshot (UserDefaults) + downloaded avatars (container files).
"com.apple.security.application-groups": ["group.app.pegada"],
// Communication-notification styling for chat pushes (the
// notification-service target carries the same entitlement; both need
// the capability enabled on their App IDs in the Apple Developer
// portal for device builds).
"com.apple.developer.usernotifications.communication": true,
},
// associatedDomains: [
// 'applinks:pegada.app',
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
"babel-plugin-styled-components": "^2.1.4",
"jest": "^29.7.0"
},
"jest": {
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
},
"installConfig": {
"hoistingLimits": "workspaces"
}
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/services/getPushNotificationToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import Color from "color";

import {
NOTIFICATION_ACTION,
NOTIFICATION_CATEGORY,
NOTIFICATION_CHANNEL,
} from "@pegada/shared/constants/notifications";
import { LightTheme } from "@pegada/shared/themes/themes";

import { getTrcpContext } from "@/contexts/trcpContext";
import i18n from "@/i18n";

Notifications.setNotificationHandler({
handleNotification: async () => ({
Expand All @@ -22,6 +28,22 @@ export enum NotificationTokenError {
Denied = "Push notifications denied",
}

// Lets a chat-message push carry a text-input "Reply" action on both
// platforms, so the user can answer straight from the notification
// without opening the app. Handled in `services/linking`.
const registerNotificationCategories = async () => {
await Notifications.setNotificationCategoryAsync(NOTIFICATION_CATEGORY.ChatMessage, [
{
identifier: NOTIFICATION_ACTION.Reply,
buttonTitle: i18n.t("chat.replyAction"),
textInput: {
submitButtonTitle: i18n.t("chat.replyAction"),
placeholder: "",
},
},
]);
};

export const getPushNotificationToken = async () => {
if (!Device.isDevice) return;

Expand All @@ -32,8 +54,19 @@ export const getPushNotificationToken = async () => {
vibrationPattern: [0, 250, 250, 250],
lightColor: Color(LightTheme.colors.primary).alpha(0.7).hex(),
});

// Dedicated channel for chat-message pushes, matched server-side by the
// same shared id (see MessageService).
await Notifications.setNotificationChannelAsync(NOTIFICATION_CHANNEL.ChatMessage, {
name: i18n.t("chat.notificationChannelName"),
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: Color(LightTheme.colors.primary).alpha(0.7).hex(),
});
}

await registerNotificationCategories();

const { status: existingStatus } = await Notifications.getPermissionsAsync();

// Makes sure the user has accepted push notifications permissions
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/services/linking/handlers/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { router } from "expo-router";
import { sendError } from "@/services/errorTracking";
import { SceneName } from "@/types/SceneName";

enum NotificationUrl {
export enum NotificationUrl {
Match = "match/",
Chat = "chat/",
}
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/services/linking/handlers/reply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as Notifications from "expo-notifications";

import { NOTIFICATION_ACTION, NOTIFICATION_CATEGORY } from "@pegada/shared/constants/notifications";

import { getTrcpContext } from "@/contexts/trcpContext";
import { sendError } from "@/services/errorTracking";
import { getMatchIdFromUrl, handleReplyAction, isReplyAction } from "./reply";

jest.mock("expo-notifications", () => ({ scheduleNotificationAsync: jest.fn() }));
// Pulled in transitively by ./notification, and untransformed by jest.
jest.mock("expo-router", () => ({ router: { push: jest.fn() } }));
jest.mock("@/contexts/trcpContext", () => ({ getTrcpContext: jest.fn() }));
jest.mock("@/services/errorTracking", () => ({ sendError: jest.fn() }));
jest.mock("@/i18n", () => ({ __esModule: true, default: { t: (key: string) => key } }));

const mutate = jest.fn();
const scheduleNotificationAsync = Notifications.scheduleNotificationAsync as jest.Mock;
const sendErrorMock = sendError as jest.Mock;

(getTrcpContext as jest.Mock).mockReturnValue({ client: { message: { send: { mutate } } } });

const response = (overrides: {
actionIdentifier?: string;
categoryIdentifier?: string;
url?: string;
userText?: string;
}) =>
({
actionIdentifier: overrides.actionIdentifier ?? NOTIFICATION_ACTION.Reply,
userText: overrides.userText,
notification: {
request: {
content: {
categoryIdentifier: overrides.categoryIdentifier ?? NOTIFICATION_CATEGORY.ChatMessage,
data: { url: overrides.url ?? "chat/match-1/dog-2" },
},
},
},
}) as unknown as Notifications.NotificationResponse;

beforeEach(() => {
jest.clearAllMocks();
});

describe("isReplyAction", () => {
test("only claims a reply on the chat-message category", () => {
expect(isReplyAction(response({}))).toBe(true);
expect(
isReplyAction(response({ actionIdentifier: "expo.modules.notifications.actions.DEFAULT" })),
).toBe(false);
expect(isReplyAction(response({ categoryIdentifier: "match" }))).toBe(false);
});
});

describe("getMatchIdFromUrl", () => {
test.each([
["chat/match-1/dog-2", "match-1"],
["match/match-1/dog-2", undefined],
[undefined, undefined],
])("reads %p as %p", (url, expected) => {
expect(getMatchIdFromUrl(url)).toBe(expected);
});
});

describe("handleReplyAction", () => {
test("sends the typed text to the match the push came from", async () => {
await handleReplyAction(response({ userText: " on my way " }));

expect(mutate).toHaveBeenCalledWith({ matchId: "match-1", content: "on my way" });
expect(scheduleNotificationAsync).not.toHaveBeenCalled();
});

test.each([
["empty text", { userText: " " }],
["a non-chat url", { userText: "hi", url: "match/match-1/dog-2" }],
])("reports %s instead of sending", async (_label, overrides) => {
await handleReplyAction(response(overrides));

expect(mutate).not.toHaveBeenCalled();
expect(sendErrorMock).toHaveBeenCalled();
});

test("tells the user when the send fails, since the notification is already gone", async () => {
const error = new Error("offline");
mutate.mockRejectedValueOnce(error);

await handleReplyAction(response({ userText: "hi" }));

expect(sendErrorMock).toHaveBeenCalledWith(error);
expect(scheduleNotificationAsync).toHaveBeenCalledWith({
content: { title: "chat.replyFailed", sound: false },
trigger: null,
});
});
});
65 changes: 65 additions & 0 deletions apps/mobile/src/services/linking/handlers/reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as Notifications from "expo-notifications";

import { NOTIFICATION_ACTION, NOTIFICATION_CATEGORY } from "@pegada/shared/constants/notifications";

import { getTrcpContext } from "@/contexts/trcpContext";
import i18n from "@/i18n";
import { sendError } from "@/services/errorTracking";
import { getNotificationUrl, NotificationUrl } from "./notification";

// Chat-message pushes carry `chat/<matchId>/<dogId>` in `data.url` (see
// MessageService, server-side). Reused here to know which match the
// "Reply" text-input action should send to.
export const getMatchIdFromUrl = (url?: string): string | undefined => {
if (!url?.startsWith(NotificationUrl.Chat)) return undefined;

const [matchId] = url.replace(NotificationUrl.Chat, "").split("/");
return matchId;
};

export const isReplyAction = (response: Notifications.NotificationResponse) => {
const category = response.notification.request.content.categoryIdentifier;

return (
response.actionIdentifier === NOTIFICATION_ACTION.Reply &&
category === NOTIFICATION_CATEGORY.ChatMessage
);
};

// Submitting the reply dismisses the notification, so a failed send would
// otherwise disappear without the user ever knowing. The in-app composer marks
// the bubble as failed for the same reason; off-screen, a notification is the
// only channel left.
const warnReplyFailed = () =>
Notifications.scheduleNotificationAsync({
content: { title: i18n.t("chat.replyFailed"), sound: false },
trigger: null,
});

/**
* Handles the "Reply" text-input action on a chat-message notification by
* sending the typed text through the same tRPC mutation the Chat screen
* uses, so it works without that screen being mounted.
*
* Called from `useGetInitialNotifications` for both a live response and the
* cold-launch one. If the app was killed, `opensAppToForeground` (default true
* on the action) brings it to the foreground first so this can run; there is no
* reliable way with expo-notifications alone to send the reply without that.
*/
export const handleReplyAction = async (response: Notifications.NotificationResponse) => {
const content = response.userText?.trim();
const url = getNotificationUrl(response);
const matchId = getMatchIdFromUrl(url);

if (!content || !matchId) {
sendError(new Error("Invalid reply notification: missing content or matchId"));
return;
}

try {
await getTrcpContext().client.message.send.mutate({ matchId, content });
} catch (error) {
sendError(error);
await warnReplyFailed();
}
};
28 changes: 27 additions & 1 deletion apps/mobile/src/services/linking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Notifications from "expo-notifications";
import { sendError } from "@/services/errorTracking";
import { initialNotification, setInitialNotification } from "./handlers/initialNotification";
import { customNotificationHandler, getNotificationUrl } from "./handlers/notification";
import { handleReplyAction, isReplyAction } from "./handlers/reply";

export const processLinks = () => {
if (initialNotification) {
Expand All @@ -15,6 +16,11 @@ export const processLinks = () => {
// When the app is already running, and the user clicks on a notification
const notificationSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
// The "Reply" action is already handled by the root listener in
// `useGetInitialNotifications` - skip it here so we don't send the
// message twice or navigate into the chat the user didn't tap into.
if (isReplyAction(response)) return;

const url = getNotificationUrl(response);
customNotificationHandler(url).catch(sendError);
},
Expand All @@ -33,14 +39,34 @@ export const useGetInitialNotifications = () => {
Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (!response) return;

// A reply typed on a killed app only ever surfaces here. Native buffers
// the launch response and replays it into the module the moment it is
// created, which is before any JS listener exists, so the emitted event
// goes nowhere and only `lastResponse` survives (see
// NotificationCenterManager.pendingResponses on iOS and
// NotificationManager's listener replay on Android). Sending from the
// listener alone silently dropped the message.
if (isReplyAction(response)) {
handleReplyAction(response).catch(sendError);
return;
}

const url = getNotificationUrl(response);
setInitialNotification(url);
})
.catch(sendError);

// When the app is already running, and the user clicks on a notification
// Registered here (root, mounted for the whole app lifetime) rather
// than in `processLinks`, so the "Reply" action on a chat-message push
// is handled even if the user never navigates to the Swipe screen.
const notificationSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
if (isReplyAction(response)) {
handleReplyAction(response).catch(sendError);
return;
}

const url = getNotificationUrl(response);
setInitialNotification(url);
},
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/targets/notification-service/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Hand-maintained (apple-targets only generates this file when absent).
IntentsSupported/INSendMessageIntent is what marks the extension as
capable of upgrading pushes into communication notifications.
-->
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
Loading
Loading