diff --git a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj index f353cde029..a56502bb99 100644 --- a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj +++ b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj @@ -710,7 +710,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DRCT_REMOVE_LEGACY_ARCH=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; @@ -862,7 +865,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DRCT_REMOVE_LEGACY_ARCH=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; diff --git a/package/expo-package/src/optionalDependencies/setClipboardString.ts b/package/expo-package/src/optionalDependencies/setClipboardString.ts index bfd4ba37b8..c81e71e8e3 100644 --- a/package/expo-package/src/optionalDependencies/setClipboardString.ts +++ b/package/expo-package/src/optionalDependencies/setClipboardString.ts @@ -1,4 +1,14 @@ -let Clipboard: { setString: (string: string) => void } | undefined; +type SetClipboardStringOptions = { + onFailure?: (error: unknown) => void; + onSuccess?: () => void; +}; + +let Clipboard: + | { + setString?: (text: string) => void; + setStringAsync?: (text: string) => Promise; + } + | undefined; try { Clipboard = require('expo-clipboard'); @@ -12,6 +22,35 @@ if (!Clipboard) { ); } -export const setClipboardString = Clipboard - ? (string: string) => Clipboard?.setString(string) - : null; +export const setClipboardString = + Clipboard && (Clipboard.setStringAsync || Clipboard.setString) + ? (text: string, options?: SetClipboardStringOptions) => { + try { + // `expo-clipboard` removed the synchronous `setString` in favour of + // the async API. Keep this handler synchronous (void return, + // like the legacy `setString`) and fire the async write without + // awaiting, reporting the outcome through the callbacks. Fall back to + // `setString` for older versions. + if (Clipboard?.setStringAsync) { + Clipboard.setStringAsync(text) + .then((success) => { + if (success) { + options?.onSuccess?.(); + } else { + options?.onFailure?.(new Error('Copying to clipboard failed')); + } + }) + .catch((error: unknown) => { + console.log('Copying to clipboard failed...', error); + options?.onFailure?.(error); + }); + } else { + Clipboard?.setString?.(text); + options?.onSuccess?.(); + } + } catch (error) { + console.log('Copying to clipboard failed...', error); + options?.onFailure?.(error); + } + } + : null; diff --git a/package/native-package/src/optionalDependencies/setClipboardString.ts b/package/native-package/src/optionalDependencies/setClipboardString.ts index 030609cc87..260fbadc9b 100644 --- a/package/native-package/src/optionalDependencies/setClipboardString.ts +++ b/package/native-package/src/optionalDependencies/setClipboardString.ts @@ -1,4 +1,9 @@ -let Clipboard: { setString: (string: string) => void } | undefined; +type SetClipboardStringOptions = { + onFailure?: (error: unknown) => void; + onSuccess?: () => void; +}; + +let Clipboard: { setString?: (text: string) => void } | undefined; try { Clipboard = require('@react-native-clipboard/clipboard').default; @@ -7,6 +12,14 @@ try { console.log('@react-native-clipboard/clipboard is not installed'); } -export const setClipboardString = Clipboard - ? (string: string) => (Clipboard ? Clipboard.setString(string) : {}) +export const setClipboardString = Clipboard?.setString + ? (text: string, options?: SetClipboardStringOptions) => { + try { + Clipboard?.setString?.(text); + options?.onSuccess?.(); + } catch (error) { + console.log('Copying to clipboard failed...', error); + options?.onFailure?.(error); + } + } : null; diff --git a/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx b/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx index bb2ed24d30..5a8ea443ba 100644 --- a/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx @@ -5,6 +5,7 @@ import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; import { ChannelProvider } from '../../../../contexts/channelContext/ChannelContext'; import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { NativeHandlers } from '../../../../native'; import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; import { useMessageActionHandlers } from '../useMessageActionHandlers'; @@ -129,3 +130,73 @@ describe('useMessageActionHandlers notifications', () => { }); }); }); + +describe('useMessageActionHandlers copy', () => { + const originalSetClipboardString = NativeHandlers.setClipboardString; + + afterEach(() => { + NativeHandlers.setClipboardString = originalSetClipboardString; + }); + + it('notifies when copying a message succeeds', () => { + NativeHandlers.setClipboardString = jest.fn((_text, options) => options?.onSuccess?.()); + const client = createClient(); + const message = createMessage(); + const { result } = renderUseMessageActionHandlers({ client, message }); + + act(() => { + result.current.handleCopyMessage(); + }); + + expect(NativeHandlers.setClipboardString).toHaveBeenCalledWith( + 'Message text', + expect.objectContaining({ onFailure: expect.any(Function), onSuccess: expect.any(Function) }), + ); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Message copied to clipboard', + options: { + severity: 'success', + tags: ['target:channel:channel:messaging:general'], + type: 'clipboard:message:copy:success', + }, + origin: { context: { message }, emitter: 'MessageActions' }, + }); + }); + + it('notifies when copying a message fails', () => { + const error = new Error('Clipboard unavailable'); + NativeHandlers.setClipboardString = jest.fn((_text, options) => options?.onFailure?.(error)); + const client = createClient(); + const message = createMessage(); + const { result } = renderUseMessageActionHandlers({ client, message }); + + act(() => { + result.current.handleCopyMessage(); + }); + + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to copy message', + options: { + originalError: error, + severity: 'error', + tags: ['target:channel:channel:messaging:general'], + type: 'clipboard:message:copy:failed', + }, + origin: { context: { message }, emitter: 'MessageActions' }, + }); + }); + + it('does not copy or notify when the message has no text', () => { + NativeHandlers.setClipboardString = jest.fn(); + const client = createClient(); + const message = createMessage({ text: '' }); + const { result } = renderUseMessageActionHandlers({ client, message }); + + act(() => { + result.current.handleCopyMessage(); + }); + + expect(NativeHandlers.setClipboardString).not.toHaveBeenCalled(); + expect(client.notifications.add).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 8ed432d9c7..8efb4aaea1 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -87,7 +87,26 @@ export const useMessageActionHandlers = ({ if (!message.text) { return; } - NativeHandlers.setClipboardString(translatedMessage?.text ?? message.text); + NativeHandlers.setClipboardString(translatedMessage?.text ?? message.text, { + onFailure: (error) => { + addNotification({ + message: t('Failed to copy message'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'clipboard:message:copy:failed', + }, + origin: { context: { message }, emitter: 'MessageActions' }, + }); + }, + onSuccess: () => { + addNotification({ + message: t('Message copied to clipboard'), + options: { severity: 'success', type: 'clipboard:message:copy:success' }, + origin: { context: { message }, emitter: 'MessageActions' }, + }); + }, + }); }); const handleDeleteMessage = useStableCallback(() => { diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index e536704036..9545265f23 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "ستتوقف عن تلقي الرسائل من {{ name }}. يمكنك الانضمام مجددًا في أي وقت.", "group": "المجموعة", "No members found": "لم يتم العثور على أعضاء", - "a11y/Search members": "البحث عن الأعضاء" + "a11y/Search members": "البحث عن الأعضاء", + "Message copied to clipboard": "تم نسخ الرسالة إلى الحافظة", + "Failed to copy message": "فشل نسخ الرسالة" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index c5aef6ae48..09bf73e0cf 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -76,6 +76,7 @@ "Mark as Unread": "Mark as Unread", "Maximum number of files reached": "Maximum number of files reached", "Message Reactions": "Message Reactions", + "Message copied to clipboard": "Message copied to clipboard", "Message deleted": "Message deleted", "Message has been successfully flagged": "Message has been successfully flagged", "Message flagged": "Message flagged", @@ -393,6 +394,7 @@ "Command not available while replying": "Command not available while replying", "Error reproducing the recording": "Error reproducing the recording", "Error uploading attachment": "Error uploading attachment", + "Failed to copy message": "Failed to copy message", "Failed to create the poll": "Failed to create the poll", "Failed to create the poll due to {{reason}}": "Failed to create the poll due to {{reason}}", "Failed to end the poll": "Failed to end the poll", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 83bf0d0443..ced6a3f81c 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Dejarás de recibir mensajes de {{ name }}. Puedes volver a unirte cuando quieras.", "group": "el grupo", "No members found": "No se encontraron miembros", - "a11y/Search members": "Buscar miembros" + "a11y/Search members": "Buscar miembros", + "Message copied to clipboard": "Mensaje copiado al portapapeles", + "Failed to copy message": "No se pudo copiar el mensaje" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 86b0d5799e..6b36a1f43f 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Vous ne recevrez plus de messages de {{ name }}. Vous pouvez rejoindre à tout moment.", "group": "ce groupe", "No members found": "Aucun membre trouvé", - "a11y/Search members": "Rechercher des membres" + "a11y/Search members": "Rechercher des membres", + "Message copied to clipboard": "Message copié dans le presse-papiers", + "Failed to copy message": "Échec de la copie du message" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index b839d384c4..5d903d4411 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "תפסיק/תפסיקי לקבל הודעות מ-{{ name }}. ניתן להצטרף בחזרה בכל עת.", "group": "הקבוצה", "No members found": "לא נמצאו חברים", - "a11y/Search members": "חיפוש חברים" + "a11y/Search members": "חיפוש חברים", + "Message copied to clipboard": "ההודעה הועתקה ללוח", + "Failed to copy message": "העתקת ההודעה נכשלה" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index f4b9410571..5097400d77 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "आपको {{ name }} से संदेश मिलना बंद हो जाएंगे। आप कभी भी दोबारा जुड़ सकते हैं।", "group": "ग्रुप", "No members found": "कोई सदस्य नहीं मिला", - "a11y/Search members": "सदस्य खोजें" + "a11y/Search members": "सदस्य खोजें", + "Message copied to clipboard": "संदेश क्लिपबोर्ड पर कॉपी किया गया", + "Failed to copy message": "संदेश कॉपी करने में विफल रहा" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 39a13522bb..48ac2c4bcb 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Non riceverai più messaggi da {{ name }}. Puoi rientrare in qualsiasi momento.", "group": "il gruppo", "No members found": "Nessun membro trovato", - "a11y/Search members": "Cerca membri" + "a11y/Search members": "Cerca membri", + "Message copied to clipboard": "Messaggio copiato negli appunti", + "Failed to copy message": "Impossibile copiare il messaggio" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index d7b2ef5b08..1269687ee2 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }} からのメッセージが届かなくなります。いつでも再参加できます。", "group": "グループ", "No members found": "メンバーが見つかりません", - "a11y/Search members": "メンバーを検索" + "a11y/Search members": "メンバーを検索", + "Message copied to clipboard": "メッセージをクリップボードにコピーしました", + "Failed to copy message": "メッセージのコピーに失敗しました" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index f0bd18d3bf..8affa8cace 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }}에서 더 이상 메시지를 받지 않게 됩니다. 언제든지 다시 참여할 수 있습니다.", "group": "그룹", "No members found": "멤버를 찾을 수 없습니다", - "a11y/Search members": "멤버 검색" + "a11y/Search members": "멤버 검색", + "Message copied to clipboard": "메시지가 클립보드에 복사되었습니다", + "Failed to copy message": "메시지 복사에 실패했습니다" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 34c7d9038a..ed1b2b01ce 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Je ontvangt geen berichten meer van {{ name }}. Je kunt op elk moment opnieuw deelnemen.", "group": "de groep", "No members found": "Geen leden gevonden", - "a11y/Search members": "Zoek leden" + "a11y/Search members": "Zoek leden", + "Message copied to clipboard": "Bericht gekopieerd naar klembord", + "Failed to copy message": "Kopiëren van bericht mislukt" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index f0001b5c5f..8ecec16566 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Você deixará de receber mensagens de {{ name }}. Pode entrar novamente a qualquer momento.", "group": "o grupo", "No members found": "Nenhum membro encontrado", - "a11y/Search members": "Pesquisar membros" + "a11y/Search members": "Pesquisar membros", + "Message copied to clipboard": "Mensagem copiada para a área de transferência", + "Failed to copy message": "Falha ao copiar a mensagem" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 897c3daa1d..a6d33d6d47 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Вы перестанете получать сообщения от {{ name }}. Вы можете присоединиться снова в любое время.", "group": "группы", "No members found": "Участники не найдены", - "a11y/Search members": "Поиск участников" + "a11y/Search members": "Поиск участников", + "Message copied to clipboard": "Сообщение скопировано в буфер обмена", + "Failed to copy message": "Не удалось скопировать сообщение" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 35782fd7a6..c6560782e3 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -465,5 +465,7 @@ "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }} kanalından mesaj almayı bırakacaksınız. İstediğiniz zaman tekrar katılabilirsiniz.", "group": "grup", "No members found": "Üye bulunamadı", - "a11y/Search members": "Üye ara" + "a11y/Search members": "Üye ara", + "Message copied to clipboard": "Mesaj panoya kopyalandı", + "Failed to copy message": "Mesaj kopyalanamadı" } diff --git a/package/src/native.ts b/package/src/native.ts index ee957bd2f1..b1a19e39f4 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -88,7 +88,16 @@ type SaveFileOptions = { }; type SaveFile = (options: SaveFileOptions) => Promise | never; -type SetClipboardString = (text: string) => Promise | never; +type SetClipboardStringOptions = { + /** Invoked when writing to the clipboard fails. */ + onFailure?: (error: unknown) => void; + /** Invoked after the text has been handed to the clipboard successfully. */ + onSuccess?: () => void; +}; +// TODO(next-major): make this handler async and return the write outcome directly. +// It's kept synchronous (void) for backwards compatibility, so success/failure are +// reported via the `onSuccess`/`onFailure` callbacks instead of a Promise. +type SetClipboardString = (text: string, options?: SetClipboardStringOptions) => void; type ShareOptions = { type?: string;