Skip to content

Commit 1f91cb0

Browse files
committed
Add "Copy as plain text" feature for chat messages, including translations and clipboard functionality
1 parent ccc684a commit 1f91cb0

3 files changed

Lines changed: 57 additions & 3 deletions

File tree

common/messages/de.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@
589589
"messages.cannot_message_deleted": "Sie können ihnen keine Nachricht senden, da sie ihr Konto gelöscht haben.",
590590
"messages.cannot_message_banned": "Sie können ihnen keine Nachricht senden, da sie gesperrt wurden.",
591591
"messages.cannot_message_you_banned": "Sie können ihnen keine Nachricht senden, da Sie gesperrt wurden.",
592+
"messages.copy.me_suffix": "(ich)",
592593
"messages.create": "Erstellen",
593594
"messages.delete_confirm": "Sind Sie sicher, dass Sie diese Nachricht löschen möchten?",
594595
"messages.delete_failed": "Löschen der Nachricht fehlgeschlagen",
@@ -597,6 +598,7 @@
597598
"messages.edited": "bearbeitet",
598599
"messages.empty": "Sie haben noch keine Nachrichten.",
599600
"messages.input_placeholder": "Nachricht senden",
601+
"messages.menu.copy_plain_text": "Als Klartext kopieren",
600602
"messages.menu.leave_chat": "Unterhaltung verlassen",
601603
"messages.menu.mute_1_day": "Benachrichtigungen für 1 Tag stummschalten",
602604
"messages.menu.mute_forever": "Benachrichtigungen dauerhaft stummschalten",
@@ -606,6 +608,8 @@
606608
"messages.no_access": "Sie haben keinen Zugriff auf diese Konversation",
607609
"messages.seo.description": "Ihre Nachrichten",
608610
"messages.title": "Nachrichten",
611+
"messages.toast.copy_empty": "Keine Nachrichten zum Kopieren",
612+
"messages.toast.copy_success": "Unterhaltung kopiert",
609613
"messages.toast.edit_failed": "Bearbeiten der Nachricht fehlgeschlagen.",
610614
"messages.toast.muting_1_day.error": "Stummschaltung der Benachrichtigungen fehlgeschlagen",
611615
"messages.toast.muting_1_day.loading": "Benachrichtigungen für 1 Tag stummschalten...",

common/messages/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@
589589
"messages.cannot_message_deleted": "Vous ne pouvez pas leur envoyer de message car ils ont supprimé leur compte.",
590590
"messages.cannot_message_banned": "Ce profil a été supprimé en raison d'une activité suspecte; n'envoyez jamais d'argent à une personne que vous n'avez jamais rencontrée.",
591591
"messages.cannot_message_you_banned": "Vous ne pouvez pas leur envoyer de message car vous avez été banni.",
592+
"messages.copy.me_suffix": "(moi)",
592593
"messages.create": "Créer",
593594
"messages.delete_confirm": "Êtes-vous sûr de vouloir supprimer ce message ?",
594595
"messages.delete_failed": "Échec de la suppression du message",
@@ -597,6 +598,7 @@
597598
"messages.edited": "modifié",
598599
"messages.empty": "Vous n'avez pas encore de messages.",
599600
"messages.input_placeholder": "Envoyer un message",
601+
"messages.menu.copy_plain_text": "Copier en texte brut",
600602
"messages.menu.leave_chat": "Quitter la conversation",
601603
"messages.menu.mute_1_day": "Couper notifs pendant 1 jour",
602604
"messages.menu.mute_forever": "Couper notifs définitivement",
@@ -606,6 +608,8 @@
606608
"messages.no_access": "Vous n'avez pas accès à cette conversation.",
607609
"messages.seo.description": "Vos messages",
608610
"messages.title": "Messages",
611+
"messages.toast.copy_empty": "Aucun message à copier",
612+
"messages.toast.copy_success": "Conversation copiée",
609613
"messages.toast.edit_failed": "Échec de la modification du message.",
610614
"messages.toast.muting_1_day.error": "Échec de la coupure des notifs",
611615
"messages.toast.muting_1_day.loading": "Couper notifs pour 1 jour...",

web/pages/messages/[channelId].tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import {ChatMessage} from 'common/chat-message'
55
import {PrivateMessageChannel} from 'common/supabase/private-messages'
66
import {User} from 'common/user'
77
import {buildArray, filterDefined} from 'common/util/array'
8-
import {cleanDoc} from 'common/util/parse'
8+
import {cleanDoc, richTextToString} from 'common/util/parse'
99
import {DAY_MS, YEAR_MS} from 'common/util/time'
10-
import {uniq} from 'lodash'
10+
import {keyBy, uniq} from 'lodash'
1111
import {useRouter} from 'next/router'
1212
import {useCallback, useEffect, useState} from 'react'
1313
import toast from 'react-hot-toast'
14-
import {FaFlag, FaUserFriends, FaUserMinus} from 'react-icons/fa'
14+
import {FaFlag, FaRegCopy, FaUserFriends, FaUserMinus} from 'react-icons/fa'
1515
import {GiSpeakerOff} from 'react-icons/gi'
1616
import {BackButton} from 'web/components/back-button'
1717
import {ChatMessageItem, SystemChatMessageItem} from 'web/components/chat/chat-message'
@@ -45,6 +45,7 @@ import {firebaseLogin} from 'web/lib/firebase/users'
4545
import {useT} from 'web/lib/locale'
4646
import {track} from 'web/lib/service/analytics'
4747
import {useGroupedMessages, usePaginatedScrollingMessages} from 'web/lib/supabase/chat-messages'
48+
import {copyToClipboard} from 'web/lib/util/copy'
4849

4950
export default function PrivateMessagesPage() {
5051
const router = useRouter()
@@ -113,6 +114,38 @@ export const getFirstName = (name: string) => {
113114
return first
114115
}
115116

117+
/**
118+
* Renders the loaded messages as a plain-text transcript, oldest first:
119+
* Martin (me): hello
120+
*
121+
* Liz: hi
122+
* System status messages (joined/left the chat) are left out.
123+
*/
124+
export const messagesToPlainText = (
125+
messages: ChatMessage[] | undefined,
126+
user: User,
127+
otherUsers: DisplayUser[] | undefined,
128+
t: ReturnType<typeof useT>,
129+
) => {
130+
const usersById = keyBy(filterDefined(otherUsers ?? []), 'id')
131+
const nameOf = (userId: string) => {
132+
if (userId === user.id)
133+
return `${getFirstName(user.name)} ${t('messages.copy.me_suffix', '(me)')}`
134+
const name = usersById[userId]?.name
135+
return name ? getFirstName(name) : t('messages.deleted_user', 'Deleted user')
136+
}
137+
138+
return (
139+
(messages ?? [])
140+
// usePrivateMessages returns newest first; a transcript reads oldest first.
141+
.slice()
142+
.reverse()
143+
.filter((m) => m.visibility !== 'system_status')
144+
.map((m) => `${nameOf(m.userId)}: ${richTextToString(m.content).trim()}`)
145+
.join('\n\n')
146+
)
147+
}
148+
116149
export const PrivateChat = (props: {
117150
user: User
118151
channel: PrivateMessageChannel
@@ -391,6 +424,19 @@ export const PrivateChat = (props: {
391424
router.push('/messages')
392425
},
393426
},
427+
{
428+
icon: <FaRegCopy className="h-5 w-5" />,
429+
name: t('messages.menu.copy_plain_text', 'Copy as plain text'),
430+
onClick: () => {
431+
const text = messagesToPlainText(messages, user, otherUsers, t)
432+
if (!text) {
433+
toast.error(t('messages.toast.copy_empty', 'No messages to copy'))
434+
return
435+
}
436+
copyToClipboard(text)
437+
toast.success(t('messages.toast.copy_success', 'Conversation copied'))
438+
},
439+
},
394440
)}
395441
/>
396442
{showUsers && (

0 commit comments

Comments
 (0)