Repository navigation
feat(messages): typing indicator and message editing #6891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ import { | |
| } from '../../../components/buttons/Button'; | ||
| import { ArrowIcon } from '../../../components/icons/Arrow'; | ||
| import { BlockIcon } from '../../../components/icons/Block'; | ||
| import { EditIcon } from '../../../components/icons/Edit'; | ||
| import { Tooltip } from '../../../components/tooltip/Tooltip'; | ||
| import { IconSize } from '../../../components/Icon'; | ||
| import { ShellPage } from '../../../components/shell/ShellPageContext'; | ||
|
|
@@ -49,12 +50,15 @@ import { | |
| dmPeerQueryOptions, | ||
| dmConversationQueryOptions, | ||
| dmThreadQueryOptions, | ||
| dmTypingQueryOptions, | ||
| } from '../queries'; | ||
| import { DirectMessageAccess } from '../graphql'; | ||
| import { DmAccess, getDmAccess } from '../access'; | ||
| import { useDmSettings } from '../hooks/useDmSettings'; | ||
| import { useSendMessage } from '../hooks/useSendMessage'; | ||
| import { useReactToMessage } from '../hooks/useReactToMessage'; | ||
| import { useEditMessage } from '../hooks/useEditMessage'; | ||
| import { useTypingNotifier } from '../hooks/useTypingNotifier'; | ||
| import { useMarkThreadRead } from '../hooks/useMarkThreadRead'; | ||
| import type { DmMessage } from '../types'; | ||
| import { DmMessageStatus } from '../types'; | ||
|
|
@@ -65,7 +69,9 @@ import { MessageRequestResponse } from './MessageRequestResponse'; | |
| import { DmContextCard } from './DmContextCard'; | ||
| import { MessageCommentRef } from './MessageCommentRef'; | ||
| import { AddReactionButton, MessageReactions } from './MessageReactions'; | ||
| import { parseMessageBody } from '../media'; | ||
| import { MessageEditor } from './MessageEditor'; | ||
| import { TypingIndicator } from './TypingIndicator'; | ||
| import { getEditableText, parseMessageBody, withEditedText } from '../media'; | ||
| import { findPostIdInText, splitLinks } from '../messageLinks'; | ||
| import { getMessagesUrl } from '../urls'; | ||
| import type { DmOrigin } from '../urls'; | ||
|
|
@@ -120,6 +126,10 @@ const DaySeparator = ({ date }: { date: Date }): ReactElement => { | |
| ); | ||
| }; | ||
|
|
||
| // Shown on hover with a mouse; always there on touch screens. | ||
| const hoverActionClassName = | ||
| 'mouse:invisible mouse:group-focus-within:visible mouse:group-hover:visible'; | ||
|
|
||
| const MessageText = ({ text }: { text: string }): ReactElement => ( | ||
| <> | ||
| {splitLinks(text).map((segment, index) => | ||
|
|
@@ -146,22 +156,32 @@ const MessageBubble = ({ | |
| message, | ||
| isMine, | ||
| isGroupEnd, | ||
| isEditing, | ||
| peerUsername, | ||
| viewerId, | ||
| onRetry, | ||
| onReact, | ||
| onEdit, | ||
| onSaveEdit, | ||
| onCancelEdit, | ||
| onMediaLoad, | ||
| }: { | ||
| message: DmMessage; | ||
| isMine: boolean; | ||
| isGroupEnd: boolean; | ||
| isEditing: boolean; | ||
| peerUsername: string; | ||
| viewerId: string; | ||
| onRetry: (message: DmMessage) => void; | ||
| onReact?: (message: DmMessage, emoji: string) => void; | ||
| // Missing when the message isn't the viewer's to change. | ||
| onEdit?: (message: DmMessage) => void; | ||
| onSaveEdit: (message: DmMessage, body: string) => void; | ||
| onCancelEdit: () => void; | ||
| onMediaLoad: () => void; | ||
| }): ReactElement => { | ||
| const parts = parseMessageBody(message.body); | ||
| const lastTextIndex = parts.map((part) => part.type).lastIndexOf('text'); | ||
| const postId = findPostIdInText( | ||
| parts.map((part) => (part.type === 'text' ? part.text : '')).join('\n'), | ||
| ); | ||
|
|
@@ -196,9 +216,24 @@ const MessageBubble = ({ | |
| className={classNames( | ||
| 'min-w-0 max-w-[85%] gap-1 tablet:max-w-[30rem]', | ||
| isMine ? 'items-end' : 'items-start', | ||
| isEditing && 'w-full', | ||
| )} | ||
| > | ||
| {isEditing && ( | ||
| <MessageEditor | ||
| initialValue={getEditableText(message.body)} | ||
| onSave={(text) => | ||
| onSaveEdit(message, withEditedText(message.body, text)) | ||
| } | ||
| onCancel={onCancelEdit} | ||
| /> | ||
| )} | ||
| {parts.map((part, index) => { | ||
| // The text is in the editor; images stay in view. | ||
| if (isEditing && part.type === 'text') { | ||
| return null; | ||
| } | ||
|
|
||
| const isLast = index === parts.length - 1; | ||
| const shape = classNames( | ||
| isMine && isGroupEnd && isLast && 'rounded-br-4', | ||
|
|
@@ -246,6 +281,14 @@ const MessageBubble = ({ | |
| )} | ||
| > | ||
| <MessageText text={part.text} /> | ||
| {message.editedAt && index === lastTextIndex && ( | ||
| <span | ||
| title={formatDateTime(message.editedAt)} | ||
| className="ml-1 text-text-quaternary typo-caption2" | ||
| > | ||
| (edited) | ||
| </span> | ||
| )} | ||
| </div> | ||
| ); | ||
| })} | ||
|
|
@@ -257,11 +300,21 @@ const MessageBubble = ({ | |
| /> | ||
| )} | ||
| </FlexCol> | ||
| {react && ( | ||
| <AddReactionButton | ||
| onReact={react} | ||
| className="mouse:invisible mouse:group-focus-within:visible mouse:group-hover:visible" | ||
| /> | ||
| {!isEditing && onEdit && ( | ||
| <Tooltip content="Edit"> | ||
| <Button | ||
| type="button" | ||
| variant={ButtonVariant.Tertiary} | ||
| size={ButtonSize.XSmall} | ||
| icon={<EditIcon />} | ||
| aria-label="Edit message" | ||
| className={hoverActionClassName} | ||
| onClick={() => onEdit(message)} | ||
| /> | ||
| </Tooltip> | ||
| )} | ||
| {!isEditing && react && ( | ||
| <AddReactionButton onReact={react} className={hoverActionClassName} /> | ||
| )} | ||
| </div> | ||
| {Object.keys(reactions).length > 0 && ( | ||
|
|
@@ -392,6 +445,11 @@ export const ConversationThread = ({ | |
| const { allowsMessages } = useDmSettings(); | ||
| const { send, retry } = useSendMessage(peer); | ||
| const react = useReactToMessage(peer); | ||
| const edit = useEditMessage(peer); | ||
| const [editingId, setEditingId] = useState<string>(); | ||
| const { data: isPeerTyping = false } = useQuery( | ||
| dmTypingQueryOptions(user, peerId), | ||
| ); | ||
|
|
||
| const isBlockedByMe = preference?.status === ContentPreferenceStatus.Blocked; | ||
| const access = getDmAccess({ | ||
|
|
@@ -400,6 +458,21 @@ export const ConversationThread = ({ | |
| peerAccess: peer?.access ?? DirectMessageAccess.Open, | ||
| hasIncomingRequest, | ||
| }); | ||
| const typing = useTypingNotifier(peer, access === DmAccess.Allowed); | ||
| // Only a delivered message of the viewer's, with text to change, can be | ||
| // edited; the request note lives in the API, not the chat archive. | ||
| const canEdit = (message: DmMessage): boolean => | ||
| access === DmAccess.Allowed && | ||
| message.senderId === user?.id && | ||
| message.status === DmMessageStatus.Sent && | ||
| archived.includes(message) && | ||
| !!getEditableText(message.body); | ||
| const editLast = () => { | ||
| const last = [...archived].reverse().find(canEdit); | ||
| if (last) { | ||
| setEditingId(last.id); | ||
| } | ||
| }; | ||
| useMarkThreadRead({ | ||
| peerId, | ||
| lastIncomingId: [...archived] | ||
|
|
@@ -446,6 +519,15 @@ export const ConversationThread = ({ | |
| setUnseenCount((count) => count + arrived); | ||
| }, [messages.length, peerId, user?.id]); | ||
|
|
||
| // The indicator appears under the newest message, where a reader at the | ||
| // bottom expects to see it. | ||
| useLayoutEffect(() => { | ||
| const container = scrollRef.current; | ||
| if (isPeerTyping && container && isAtBottomRef.current) { | ||
| container.scrollTop = container.scrollHeight; | ||
| } | ||
| }, [isPeerTyping]); | ||
|
|
||
| const jumpToLatest = () => { | ||
| const container = scrollRef.current; | ||
| container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); | ||
|
|
@@ -625,17 +707,31 @@ export const ConversationThread = ({ | |
| next.senderId !== message.senderId || | ||
| !isSameDay(message.createdAt, next.createdAt) | ||
| } | ||
| isEditing={message.id === editingId} | ||
| peerUsername={peer?.username ?? ''} | ||
| viewerId={user?.id ?? ''} | ||
| onRetry={retry} | ||
| // A blocked peer or DMs turned off would bounce the | ||
| // reaction. | ||
| onReact={access === DmAccess.Allowed ? react : undefined} | ||
| onEdit={ | ||
| canEdit(message) | ||
| ? ({ id }) => setEditingId(id) | ||
| : undefined | ||
| } | ||
| onSaveEdit={(target, body) => { | ||
| setEditingId(undefined); | ||
| edit(target, body); | ||
| }} | ||
| onCancelEdit={() => setEditingId(undefined)} | ||
| onMediaLoad={onMediaLoad} | ||
| /> | ||
| </React.Fragment> | ||
| ); | ||
| })} | ||
| {isPeerTyping && peer && ( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: the indicator renders whatever the thread's Reviewed by AI. |
||
| <TypingIndicator username={peer.username} /> | ||
| )} | ||
| </FlexCol> | ||
| </div> | ||
| {showJump && ( | ||
|
|
@@ -683,11 +779,14 @@ export const ConversationThread = ({ | |
| // picked first neither takes nor clears it. | ||
| onSendGif={(body) => send(body)} | ||
| onSend={(body) => { | ||
| typing.onSent(); | ||
| send(body, pendingContext); | ||
| if (pendingContext) { | ||
| clearContext(); | ||
| } | ||
| }} | ||
| onDraftChange={typing.onDraftChange} | ||
| onEditLast={editLast} | ||
| /> | ||
| </div> | ||
| ) : ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question (mobile): on touch devices this class is always visible, so every one of your own bubbles on a phone now shows a pencil next to the reaction button. Was this checked on a real phone, and is a permanent pencil per message the intended design there, rather than long-press or a menu? A phone screenshot of a thread with several own messages would settle it.
Reviewed by AI.