diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ede4964..9dbafd7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -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", @@ -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', diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 93f3f79..4a96f95 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -123,6 +123,11 @@ "babel-plugin-styled-components": "^2.1.4", "jest": "^29.7.0" }, + "jest": { + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + } + }, "installConfig": { "hoistingLimits": "workspaces" } diff --git a/apps/mobile/src/services/getPushNotificationToken.ts b/apps/mobile/src/services/getPushNotificationToken.ts index 7659f61..f6ac081 100644 --- a/apps/mobile/src/services/getPushNotificationToken.ts +++ b/apps/mobile/src/services/getPushNotificationToken.ts @@ -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 () => ({ @@ -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; @@ -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 diff --git a/apps/mobile/src/services/linking/handlers/notification.ts b/apps/mobile/src/services/linking/handlers/notification.ts index c952e0e..d8408ab 100644 --- a/apps/mobile/src/services/linking/handlers/notification.ts +++ b/apps/mobile/src/services/linking/handlers/notification.ts @@ -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/", } diff --git a/apps/mobile/src/services/linking/handlers/reply.test.ts b/apps/mobile/src/services/linking/handlers/reply.test.ts new file mode 100644 index 0000000..bc354a9 --- /dev/null +++ b/apps/mobile/src/services/linking/handlers/reply.test.ts @@ -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, + }); + }); +}); diff --git a/apps/mobile/src/services/linking/handlers/reply.ts b/apps/mobile/src/services/linking/handlers/reply.ts new file mode 100644 index 0000000..2e692a6 --- /dev/null +++ b/apps/mobile/src/services/linking/handlers/reply.ts @@ -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//` 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(); + } +}; diff --git a/apps/mobile/src/services/linking/index.ts b/apps/mobile/src/services/linking/index.ts index 89f5f38..d6d4375 100644 --- a/apps/mobile/src/services/linking/index.ts +++ b/apps/mobile/src/services/linking/index.ts @@ -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) { @@ -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); }, @@ -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); }, diff --git a/apps/mobile/targets/notification-service/Info.plist b/apps/mobile/targets/notification-service/Info.plist new file mode 100644 index 0000000..295c3a8 --- /dev/null +++ b/apps/mobile/targets/notification-service/Info.plist @@ -0,0 +1,25 @@ + + + + + + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/mobile/targets/notification-service/NotificationService.swift b/apps/mobile/targets/notification-service/NotificationService.swift new file mode 100644 index 0000000..a4cf77e --- /dev/null +++ b/apps/mobile/targets/notification-service/NotificationService.swift @@ -0,0 +1,162 @@ +import Intents +import UserNotifications + +/// Upgrades incoming chat pushes into iOS communication notifications +/// (sender dog avatar + name, iMessage-style) by donating an +/// `INSendMessageIntent` and rebuilding the notification content from it. +/// +/// The server marks chat pushes with `mutable-content: 1` and ships +/// `senderName` / `senderAvatarUrl` / `url` in the Expo push `data` payload +/// (see packages/api/src/services/MessageService.ts). +/// +/// NSE contract: no matter what fails (missing fields, avatar download, +/// intent donation), the original notification content is still delivered. +/// This class must never swallow a notification or hand back broken content. +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private var avatarTask: URLSessionDataTask? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + + guard let bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = bestAttempt + + let data = Self.expoData(from: request.content.userInfo) + + guard + let senderName = data["senderName"] as? String, !senderName.isEmpty, + let url = data["url"] as? String, !url.isEmpty + else { + // Not a payload we know how to upgrade: deliver as-is. + contentHandler(bestAttempt) + return + } + + // Deep-link convention from the server: chat//. + let parts = url.split(separator: "/").map(String.init) + let conversationId = parts.count > 1 ? parts[1] : url + let senderId = parts.count > 2 ? parts[2] : senderName + + // Group notifications per conversation even if the intent rebuild fails. + bestAttempt.threadIdentifier = conversationId + + let avatarUrl = (data["senderAvatarUrl"] as? String).flatMap(URL.init(string:)) + + downloadAvatar(from: avatarUrl) { [weak self] avatar in + guard let self else { return } + let upgraded = Self.communicationContent( + from: bestAttempt, + senderId: senderId, + senderName: senderName, + conversationId: conversationId, + avatar: avatar + ) + self.deliver(upgraded ?? bestAttempt) + } + } + + override func serviceExtensionTimeWillExpire() { + // Out of time: cancel the avatar download and ship the best we have. + avatarTask?.cancel() + if let bestAttemptContent { + deliver(bestAttemptContent) + } + } + + private func deliver(_ content: UNNotificationContent) { + contentHandler?(content) + contentHandler = nil + } + + // MARK: - Payload parsing + + /// Expo's push gateway delivers the message's `data` field under the + /// top-level `body` key of the APNs payload (dictionary, or JSON string in + /// older gateway versions). Handle both shapes defensively. + private static func expoData(from userInfo: [AnyHashable: Any]) -> [String: Any] { + if let dict = userInfo["body"] as? [String: Any] { + return dict + } + if let string = userInfo["body"] as? String, + let data = string.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + { + return dict + } + return [:] + } + + // MARK: - Avatar download + + /// Fetches the sender's avatar. Always calls `completion`, with `nil` on + /// any failure so the notification still upgrades (just without a photo). + private func downloadAvatar(from url: URL?, completion: @escaping (INImage?) -> Void) { + guard let url, url.scheme == "https" else { + completion(nil) + return + } + + // Stay well inside the NSE's ~30s budget so the intent donation and + // handoff still happen even on a slow network. + let task = URLSession.shared.dataTask(with: url) { data, response, _ in + guard + let data, !data.isEmpty, + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + completion(nil) + return + } + completion(INImage(imageData: data)) + } + avatarTask = task + task.resume() + } + + // MARK: - Communication styling + + /// Donates an incoming `INSendMessageIntent` for the sender and rebuilds + /// the notification content from it. Returns `nil` on any failure so the + /// caller can fall back to the untouched content. + private static func communicationContent( + from content: UNMutableNotificationContent, + senderId: String, + senderName: String, + conversationId: String, + avatar: INImage? + ) -> UNNotificationContent? { + let sender = INPerson( + personHandle: INPersonHandle(value: senderId, type: .unknown), + nameComponents: nil, + displayName: senderName, + image: avatar, + contactIdentifier: nil, + customIdentifier: senderId + ) + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content.body, + speakableGroupName: nil, + conversationIdentifier: conversationId, + serviceName: nil, + sender: sender, + attachments: nil + ) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + return try? content.updating(from: intent) + } +} diff --git a/apps/mobile/targets/notification-service/expo-target.config.js b/apps/mobile/targets/notification-service/expo-target.config.js new file mode 100644 index 0000000..5d65321 --- /dev/null +++ b/apps/mobile/targets/notification-service/expo-target.config.js @@ -0,0 +1,26 @@ +// Notification Service Extension (NSE) for chat pushes. Intercepts pushes +// sent with `mutable-content: 1` (see packages/api MessageService) and +// restyles them as iOS communication notifications: sender dog avatar + +// name, iMessage-style. See NotificationService.swift for the actual work. +// +// Wired into the Xcode project at prebuild time by @bacons/apple-targets +// (the `targets/` folder is CNG-safe: nothing under ios/ is committed). + +/** @type {import('@bacons/apple-targets/app.plugin').Config} */ +module.exports = { + type: "notification-service", + name: "PegadaNotificationService", + displayName: "Pegada Notification Service", + // Appended to the main app's bundle id -> app.pegada.notificationservice + bundleIdentifier: ".notificationservice", + // INSendMessageIntent + UNNotificationContent.updating(from:) need iOS 15+. + deploymentTarget: "15.1", + frameworks: ["UserNotifications", "Intents"], + entitlements: { + // Communication-notification styling. Must also be enabled on the App ID + // in the Apple Developer portal for device builds (simulator doesn't + // enforce it). The main app carries the same entitlement via + // `ios.entitlements` in app.config.ts. + "com.apple.developer.usernotifications.communication": true, + }, +}; diff --git a/apps/mobile/targets/notification-service/generated.entitlements b/apps/mobile/targets/notification-service/generated.entitlements new file mode 100644 index 0000000..7aca242 --- /dev/null +++ b/apps/mobile/targets/notification-service/generated.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.usernotifications.communication + + + \ No newline at end of file diff --git a/packages/api/src/services/MessageService.ts b/packages/api/src/services/MessageService.ts index e8fc8f4..979319e 100644 --- a/packages/api/src/services/MessageService.ts +++ b/packages/api/src/services/MessageService.ts @@ -1,5 +1,10 @@ import prisma from "@pegada/database"; +import { + NOTIFICATION_CATEGORY, + NOTIFICATION_CHANNEL, +} from "@pegada/shared/constants/notifications"; import { Language } from "@pegada/shared/i18n/types/types"; +import { IMAGE_STATUS } from "@pegada/shared/schemas/dogSchema"; import { PushNotificationService } from "./PushNotificationService"; import { TranslationService } from "./TranslationService"; @@ -64,7 +69,15 @@ class MessageService { sender: { select: { name: true, - images: true, + // First approved photo only: it rides along in the push payload so + // the iOS Notification Service Extension can render the sender's + // avatar on communication notifications. + images: { + orderBy: { position: "asc" }, + where: { status: IMAGE_STATUS.APPROVED }, + take: 1, + select: { url: true }, + }, }, }, receiver: { @@ -91,8 +104,15 @@ class MessageService { lng: this.language, replace: { name: newMessage.sender.name }, }), + channelId: NOTIFICATION_CHANNEL.ChatMessage, + categoryId: NOTIFICATION_CATEGORY.ChatMessage, + // Lets the iOS Notification Service Extension intercept the push and + // restyle it as a communication notification (sender avatar + name). + mutableContent: true, data: { url: `chat/${matchId}/${newMessage.senderId}`, + senderName: newMessage.sender.name, + senderAvatarUrl: newMessage.sender.images[0]?.url, }, }); } diff --git a/packages/shared/constants/notifications.ts b/packages/shared/constants/notifications.ts new file mode 100644 index 0000000..0d0a813 --- /dev/null +++ b/packages/shared/constants/notifications.ts @@ -0,0 +1,17 @@ +/** + * Shared between the push sender (packages/api's MessageService) and the app + * that registers them (apps/mobile's getPushNotificationToken). A mismatch + * costs the chat push its Reply action and its own Android channel, and + * nothing fails loudly, so both sides read the ids from here. + */ +export const NOTIFICATION_CHANNEL = { + ChatMessage: "messages", +} as const; + +export const NOTIFICATION_CATEGORY = { + ChatMessage: "chat-message", +} as const; + +export const NOTIFICATION_ACTION = { + Reply: "reply", +} as const; diff --git a/packages/shared/i18n/locales/en/translation.json b/packages/shared/i18n/locales/en/translation.json index d3fb45f..7d98553 100644 --- a/packages/shared/i18n/locales/en/translation.json +++ b/packages/shared/i18n/locales/en/translation.json @@ -35,6 +35,9 @@ "breed": "Breed" }, "chat": { + "notificationChannelName": "Messages", + "replyAction": "Reply", + "replyFailed": "Your reply didn't send. Open the chat to try again.", "sendAMessageToStart": "Send a message to start the conversation", "today": "Today", "yesterday": "Yesterday", diff --git a/packages/shared/i18n/locales/pt-BR/translation.json b/packages/shared/i18n/locales/pt-BR/translation.json index 87a8a8b..25df55b 100644 --- a/packages/shared/i18n/locales/pt-BR/translation.json +++ b/packages/shared/i18n/locales/pt-BR/translation.json @@ -35,6 +35,9 @@ "breed": "Raça" }, "chat": { + "notificationChannelName": "Mensagens", + "replyAction": "Responder", + "replyFailed": "Sua resposta n\u00e3o foi enviada. Abre a conversa pra tentar de novo.", "sendAMessageToStart": "Envie uma mensagem para iniciar a conversa", "today": "Hoje", "yesterday": "Ontem",