feat: merge expiring stopwatch and messagestatus together

pull/2940/head
Audric Ackermann
parent 00f93a2754
commit 615722434b

@ -109,10 +109,13 @@
"from": "From:",
"to": "To:",
"sent": "Sent",
"sending": "Sending",
"received": "Received",
"sendMessage": "Message",
"groupMembers": "Members",
"moreInformation": "More information",
"failed": "Failed",
"read": "Read",
"resend": "Resend",
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
"clear": "Clear",

@ -12,13 +12,14 @@ const ExpireTimerBucket = styled.div`
letter-spacing: 0.3px;
text-transform: uppercase;
user-select: none;
color: var(--text-primary-color);
color: var(--text-secondary-color);
align-self: center;
`;
type Props = {
expirationDurationMs: number;
expirationTimestamp: number | null;
style: CSSProperties;
expirationDurationMs?: number;
expirationTimestamp?: number | null;
style?: CSSProperties;
};
export const ExpireTimer = (props: Props) => {
@ -43,13 +44,11 @@ export const ExpireTimer = (props: Props) => {
return null;
}
const expireTimerColor = 'var(--primary-text-color)';
const bucket = getTimerBucketIcon(expirationTimestamp, expirationDurationMs);
return (
<ExpireTimerBucket style={style}>
<SessionIcon iconType={bucket} iconSize="tiny" iconColor={expireTimerColor} />
<SessionIcon iconType={bucket} iconSize="tiny" iconColor={'var(--secondary-text-color)'} />
</ExpireTimerBucket>
);
};

@ -5,8 +5,7 @@ import { assertUnreachable } from '../../types/sqlSharedTypes';
import { isLegacyDisappearingModeEnabled } from '../../session/disappearing_messages/legacy';
import { Flex } from '../basic/Flex';
import { SpacerSM, Text } from '../basic/Text';
import { SessionIcon } from '../icon';
import { Text } from '../basic/Text';
import { ExpirableReadableMessage } from './message/message-item/ExpirableReadableMessage';
export const TimerNotification = (props: PropsForExpirationTimer) => {
@ -48,7 +47,7 @@ export const TimerNotification = (props: PropsForExpirationTimer) => {
return (
<ExpirableReadableMessage
messageId={messageId}
isCentered={true}
isControlMessage={true}
key={`readable-message-${messageId}`}
dataTestId={'disappear-control-message'}
>
@ -59,13 +58,11 @@ export const TimerNotification = (props: PropsForExpirationTimer) => {
justifyContent="center"
width="90%"
maxWidth="700px"
margin="10px auto"
margin="5px auto 10px auto" // top margin is smaller that bottom one to make the stopwatch icon of expirable message closer to its content
padding="5px 10px"
style={{ textAlign: 'center' }}
>
<SessionIcon iconType="stopwatch" iconColor="inherit" iconSize="medium" />
<SpacerSM />
<Text text={textToRender} />
<Text text={textToRender} subtle={true} />
</Flex>
</ExpirableReadableMessage>
);

@ -12,6 +12,7 @@ import {
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { Reactions } from '../../../../util/reactions';
import { Flex } from '../../../basic/Flex';
import { ExpirableReadableMessage } from '../message-item/ExpirableReadableMessage';
import { MessageAuthorText } from './MessageAuthorText';
import { MessageAvatar } from './MessageAvatar';
@ -33,11 +34,11 @@ type Props = {
enableReactions: boolean;
};
const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }>`
const StyledMessageContentContainer = styled.div<{ isIncoming: boolean }>`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: ${props => (props.direction === 'left' ? 'flex-start' : 'flex-end')};
align-items: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
width: 100%;
${StyledMessageReactions} {
@ -46,7 +47,7 @@ const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }
`;
const StyledMessageWithAuthor = styled.div<{ isIncoming: boolean }>`
max-width: ${props => (props.isIncoming ? '100%' : 'calc(100% - 17px)')};
max-width: '100%';
display: flex;
flex-direction: column;
min-width: 0;
@ -118,7 +119,7 @@ export const MessageContentWithStatuses = (props: Props) => {
return (
<StyledMessageContentContainer
direction={isIncoming ? 'left' : 'right'}
isIncoming={isIncoming}
onMouseLeave={() => {
setPopupReaction('');
}}
@ -134,20 +135,14 @@ export const MessageContentWithStatuses = (props: Props) => {
<StyledAvatarContainer hideAvatar={hideAvatar} isGroup={isGroup}>
<MessageAvatar messageId={messageId} hideAvatar={hideAvatar} isPrivate={isPrivate} />
</StyledAvatarContainer>
<MessageStatus
dataTestId="msg-status-incoming"
messageId={messageId}
isCorrectSide={isIncoming}
/>
<StyledMessageWithAuthor isIncoming={isIncoming}>
<MessageAuthorText messageId={messageId} />
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</StyledMessageWithAuthor>
<MessageStatus
dataTestId="msg-status-outgoing"
messageId={messageId}
isCorrectSide={!isIncoming}
/>
<Flex container={true} flexDirection="column" flexShrink={0}>
<StyledMessageWithAuthor isIncoming={isIncoming}>
<MessageAuthorText messageId={messageId} />
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</StyledMessageWithAuthor>
<MessageStatus dataTestId="msg-status" messageId={messageId} />
</Flex>
{!isDeleted && (
<MessageContextMenu
messageId={messageId}

@ -1,34 +1,188 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import { MessageRenderingProps } from '../../../../models/messageType';
import { OutgoingMessageStatus } from './OutgoingMessageStatus';
import { useMessageDirection, useMessageStatus } from '../../../../state/selectors';
import styled from 'styled-components';
import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector';
import { useMessageStatus } from '../../../../state/selectors';
import { SpacerXS } from '../../../basic/Text';
import { SessionIcon, SessionIconType } from '../../../icon';
import { ExpireTimer } from '../../ExpireTimer';
type Props = {
isCorrectSide: boolean;
messageId: string;
dataTestId?: string;
dataTestId?: string | undefined;
};
export type MessageStatusSelectorProps = Pick<MessageRenderingProps, 'direction' | 'status'>;
export const MessageStatus = (props: Props) => {
const { isCorrectSide, dataTestId } = props;
const direction = useMessageDirection(props.messageId);
const { dataTestId, messageId } = props;
const status = useMessageStatus(props.messageId);
const selected = useMessageExpirationPropsById(props.messageId);
if (!props.messageId) {
if (!props.messageId || !selected) {
return null;
}
const isIncoming = selected.direction === 'incoming';
if (!isCorrectSide) {
return null;
if (isIncoming) {
if (selected.isUnread || !selected.expirationDurationMs || !selected.expirationTimestamp) {
return null;
}
return (
<MessageStatusRead dataTestId={dataTestId} messageId={messageId} reserveDirection={true} />
);
}
const isIncoming = direction === 'incoming';
const showStatus = !isIncoming && Boolean(status);
if (!showStatus) {
// this is the outgoing state: we display the text and the icon or the text and the expiretimer stopwatch when the message is expiring
switch (status) {
case 'sending':
return <MessageStatusSending dataTestId={dataTestId} messageId={messageId} />;
case 'sent':
return <MessageStatusSent dataTestId={dataTestId} messageId={messageId} />;
case 'read':
return <MessageStatusRead dataTestId={dataTestId} messageId={messageId} />;
case 'error':
return <MessageStatusError dataTestId={dataTestId} messageId={messageId} />;
default:
return null;
}
};
const MessageStatusContainer = styled.div<{ reserveDirection?: boolean }>`
display: inline-block;
align-self: flex-end;
margin-bottom: 2px;
margin-inline-start: 5px;
cursor: pointer;
display: flex;
align-items: baseline;
flex-direction: ${props =>
props.reserveDirection
? 'row-reverse'
: 'row'}; // we want {icon}{text} for incoming read messages, but {text}{icon} for outgoing messages
`;
const StyledStatusText = styled.div`
color: var(--text-secondary-color);
font-size: small;
`;
const TextDetails = ({ text }: { text: string }) => {
return (
<>
<StyledStatusText>{text}</StyledStatusText>
<SpacerXS />
</>
);
};
function IconDanger({ iconType }: { iconType: SessionIconType }) {
return <SessionIcon iconColor={'var(--danger-color'} iconType={iconType} iconSize="tiny" />;
}
function IconNormal({
iconType,
rotateDuration,
}: {
iconType: SessionIconType;
rotateDuration?: number | undefined;
}) {
return (
<SessionIcon
rotateDuration={rotateDuration}
iconColor={'var(--text-secondary-color)'}
iconType={iconType}
iconSize="tiny"
/>
);
}
function useIsExpiring(messageId: string) {
const selected = useMessageExpirationPropsById(messageId);
return (
selected && selected.expirationDurationMs && selected.expirationTimestamp && !selected.isExpired
);
}
function MessageStatusExpireTimer(props: Props) {
const selected = useMessageExpirationPropsById(props.messageId);
if (
!selected ||
!selected.expirationDurationMs ||
!selected.expirationTimestamp ||
selected.isExpired
) {
return null;
}
return (
<ExpireTimer
expirationDurationMs={selected.expirationDurationMs}
expirationTimestamp={selected.expirationTimestamp}
/>
);
}
const MessageStatusSending = ({ dataTestId }: Props) => {
// while sending, we do not display the expire timer at all.
return (
<MessageStatusContainer data-testid={dataTestId} data-testtype="sending">
<TextDetails text={window.i18n('sending')} />
<IconNormal rotateDuration={2} iconType="sending" />
</MessageStatusContainer>
);
};
const MessageStatusSent = ({ dataTestId, messageId }: Props) => {
const isExpiring = useIsExpiring(messageId);
return (
<MessageStatusContainer data-testid={dataTestId} data-testtype="sent">
<TextDetails text={window.i18n('sent')} />
{isExpiring ? (
<MessageStatusExpireTimer messageId={messageId} />
) : (
<IconNormal iconType="circleCheck" />
)}
</MessageStatusContainer>
);
};
const MessageStatusRead = ({
dataTestId,
messageId,
reserveDirection,
}: Props & { reserveDirection?: boolean }) => {
const isExpiring = useIsExpiring(messageId);
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="read"
reserveDirection={reserveDirection}
>
<TextDetails text={window.i18n('read')} />
{isExpiring ? (
<MessageStatusExpireTimer messageId={messageId} />
) : (
<IconNormal iconType="doubleCheckCircleFilled" />
)}
</MessageStatusContainer>
);
};
const MessageStatusError = ({ dataTestId }: Props) => {
const showDebugLog = () => {
ipcRenderer.send('show-debug-log');
};
// when on errro, we do not display the expire timer at all.
return <OutgoingMessageStatus dataTestId={dataTestId} status={status} />;
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="failed"
onClick={showDebugLog}
title={window.i18n('sendFailed')}
>
<TextDetails text={window.i18n('failed')} />
<IconDanger iconType="error" />
</MessageStatusContainer>
);
};

@ -1,75 +0,0 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import styled from 'styled-components';
import { LastMessageStatusType } from '../../../../state/ducks/conversations';
import { SessionIcon } from '../../../icon';
const MessageStatusSendingContainer = styled.div`
display: inline-block;
align-self: flex-end;
margin-bottom: 2px;
margin-inline-start: 5px;
cursor: pointer;
`;
const iconColor = 'var(--text-primary-color)';
const MessageStatusSending = ({ dataTestId }: { dataTestId?: string }) => {
return (
<MessageStatusSendingContainer data-testid={dataTestId} data-testtype="sending">
<SessionIcon rotateDuration={2} iconColor={iconColor} iconType="sending" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
const MessageStatusSent = ({ dataTestId }: { dataTestId?: string }) => {
return (
<MessageStatusSendingContainer data-testid={dataTestId} data-testtype="sent">
<SessionIcon iconColor={iconColor} iconType="circleCheck" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
const MessageStatusRead = ({ dataTestId }: { dataTestId?: string }) => {
return (
<MessageStatusSendingContainer data-testid={dataTestId} data-testtype="read">
<SessionIcon iconColor={iconColor} iconType="doubleCheckCircleFilled" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
const MessageStatusError = ({ dataTestId }: { dataTestId?: string }) => {
const showDebugLog = () => {
ipcRenderer.send('show-debug-log');
};
return (
<MessageStatusSendingContainer
data-testid={dataTestId}
data-testtype="failed"
onClick={showDebugLog}
title={window.i18n('sendFailed')}
>
<SessionIcon iconColor={'var(--danger-color'} iconType="error" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
export const OutgoingMessageStatus = (props: {
status: LastMessageStatusType | null;
dataTestId?: string;
}) => {
const { status, dataTestId } = props;
switch (status) {
case 'sending':
return <MessageStatusSending dataTestId={dataTestId} />;
case 'sent':
return <MessageStatusSent dataTestId={dataTestId} />;
case 'read':
return <MessageStatusRead dataTestId={dataTestId} />;
case 'error':
return <MessageStatusError dataTestId={dataTestId} />;
default:
return null;
}
};

@ -21,7 +21,7 @@ export const DataExtractionNotification = (props: PropsForDataExtractionNotifica
messageId={messageId}
dataTestId="data-extraction-notification"
key={`readable-message-${messageId}`}
isCentered={true}
isControlMessage={true}
>
<Flex
container={true}
@ -30,7 +30,7 @@ export const DataExtractionNotification = (props: PropsForDataExtractionNotifica
justifyContent="center"
width="90%"
maxWidth="700px"
margin="10px auto"
margin="5px auto 10px auto" // top margin is smaller that bottom one to make the stopwatch icon of expirable message closer to its content
padding="5px 10px"
id={`msg-${messageId}`}
style={{ textAlign: 'center' }}

@ -74,21 +74,42 @@ const StyledReadableMessage = styled(ReadableMessage)<{
isIncoming: boolean;
}>`
display: flex;
justify-content: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
align-items: center;
justify-content: flex-end; // ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
align-items: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
width: 100%;
flex-direction: column;
`;
export interface ExpirableReadableMessageProps
extends Omit<ReadableMessageProps, 'receivedAt' | 'isUnread'> {
messageId: string;
isCentered?: boolean;
isControlMessage?: boolean;
}
function ExpireTimerControlMessage({
expirationTimestamp,
expirationDurationMs,
isControlMessage,
}: {
expirationDurationMs: number | null | undefined;
expirationTimestamp: number | null | undefined;
isControlMessage: boolean | undefined;
}) {
if (!isControlMessage) {
return null;
}
return (
<ExpireTimer
expirationDurationMs={expirationDurationMs || undefined}
expirationTimestamp={expirationTimestamp}
/>
);
}
export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) => {
const selected = useMessageExpirationPropsById(props.messageId);
const { isCentered, onClick, onDoubleClickCapture, role, dataTestId } = props;
const { isControlMessage, onClick, onDoubleClickCapture, role, dataTestId } = props;
const { isExpired } = useIsExpired({
convoId: selected?.convoId,
@ -126,30 +147,12 @@ export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) =
key={`readable-message-${messageId}`}
dataTestId={dataTestId}
>
{expirationDurationMs && expirationTimestamp ? (
<ExpireTimer
expirationDurationMs={expirationDurationMs}
expirationTimestamp={expirationTimestamp}
style={{
display: !isCentered && isIncoming ? 'none' : 'block',
visibility: !isIncoming ? 'visible' : 'hidden',
flexGrow: !isCentered ? 1 : undefined,
}}
/>
) : null}
<ExpireTimerControlMessage
expirationDurationMs={expirationDurationMs}
expirationTimestamp={expirationTimestamp}
isControlMessage={isControlMessage}
/>
{props.children}
{expirationDurationMs && expirationTimestamp ? (
<ExpireTimer
expirationDurationMs={expirationDurationMs}
expirationTimestamp={expirationTimestamp}
style={{
display: !isCentered && !isIncoming ? 'none' : 'block',
visibility: isIncoming ? 'visible' : 'hidden',
flexGrow: !isCentered ? 1 : undefined,
textAlign: !isCentered ? 'end' : undefined,
}}
/>
) : null}
</StyledReadableMessage>
);
};

@ -1,14 +1,14 @@
import React from 'react';
import { useConversationsUsernameWithQuoteOrFullPubkey } from '../../../../hooks/useParamSelector';
import { arrayContainsUsOnly } from '../../../../models/message';
import {
PropsForGroupUpdate,
PropsForGroupUpdateType,
} from '../../../../state/ducks/conversations';
import { NotificationBubble } from './notification-bubble/NotificationBubble';
import { ExpirableReadableMessage } from './ExpirableReadableMessage';
import { arrayContainsUsOnly } from '../../../../models/message';
import { useConversationsUsernameWithQuoteOrFullPubkey } from '../../../../hooks/useParamSelector';
import { assertUnreachable } from '../../../../types/sqlSharedTypes';
import { ExpirableReadableMessage } from './ExpirableReadableMessage';
import { NotificationBubble } from './notification-bubble/NotificationBubble';
// This component is used to display group updates in the conversation view.
@ -80,7 +80,7 @@ export const GroupUpdateMessage = (props: PropsForGroupUpdate) => {
messageId={messageId}
key={`readable-message-${messageId}`}
dataTestId="group-update-message"
isCentered={true}
isControlMessage={true}
>
<NotificationBubble notificationText={ChangeItem(change)} iconType="users" />
</ExpirableReadableMessage>

@ -41,6 +41,7 @@ export type ReadableMessageProps = {
role?: AriaRole;
dataTestId: string;
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
isControlMessage?: boolean;
};
const debouncedTriggerLoadMoreTop = debounce(
@ -98,7 +99,6 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
// if this unread-indicator is rendered,
// we want to scroll here only if the conversation was not opened to a specific message
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (

@ -63,7 +63,7 @@ export const CallNotification = (props: PropsForCallNotification) => {
messageId={messageId}
key={`readable-message-${messageId}`}
dataTestId={`call-notification-${notificationType}`}
isCentered={true}
isControlMessage={true}
>
<NotificationBubble
notificationText={notificationText}

@ -8,7 +8,7 @@ const NotificationBubbleFlex = styled.div`
color: var(--text-primary-color);
width: 90%;
max-width: 700px;
margin: 10px auto;
margin: 5px auto 10px auto; // top margin is smaller that bottom one to make the stopwatch icon of expirable message closer to its content
padding: 5px 10px;
border-radius: 16px;
word-break: break-word;

@ -8,11 +8,13 @@ import {
useIsPrivate,
useIsTyping,
} from '../../../hooks/useParamSelector';
import { LastMessageStatusType } from '../../../state/ducks/conversations';
import { isSearching } from '../../../state/selectors/search';
import { getIsMessageRequestOverlayShown } from '../../../state/selectors/section';
import { assertUnreachable } from '../../../types/sqlSharedTypes';
import { TypingAnimation } from '../../conversation/TypingAnimation';
import { MessageBody } from '../../conversation/message/message-content/MessageBody';
import { OutgoingMessageStatus } from '../../conversation/message/message-content/OutgoingMessageStatus';
import { SessionIcon } from '../../icon';
import { useConvoIdFromContext } from './ConvoIdContext';
function useLastMessageFromConvo(convoId: string) {
@ -58,8 +60,39 @@ export const MessageItem = () => {
)}
</div>
{!isSearchingMode && lastMessage && lastMessage.status && !isMessageRequest ? (
<OutgoingMessageStatus status={lastMessage.status} />
<IconMessageStatus status={lastMessage.status} />
) : null}
</div>
);
};
function IconMessageStatus({ status }: { status: LastMessageStatusType }) {
const nonErrorIconColor = 'var(--text-secondary-color';
switch (status) {
case 'error':
return <SessionIcon iconColor={'var(--danger-color'} iconType="error" iconSize="tiny" />;
case 'read':
return (
<SessionIcon
iconColor={nonErrorIconColor}
iconType="doubleCheckCircleFilled"
iconSize="tiny"
/>
);
case 'sending':
return (
<SessionIcon
rotateDuration={2}
iconColor={nonErrorIconColor}
iconType="sending"
iconSize="tiny"
/>
);
case 'sent':
return <SessionIcon iconColor={nonErrorIconColor} iconType="circleCheck" iconSize="tiny" />;
case undefined:
return null;
default:
assertUnreachable(status, 'missing case error');
}
}

@ -188,6 +188,7 @@ export type LocalizerKeys =
| 'error'
| 'establishingConnection'
| 'expandedReactionsText'
| 'failed'
| 'failedResolveOns'
| 'failedToAddAsModerator'
| 'failedToRemoveFromModerator'
@ -364,6 +365,7 @@ export type LocalizerKeys =
| 'reactionPopupOne'
| 'reactionPopupThree'
| 'reactionPopupTwo'
| 'read'
| 'readReceiptSettingDescription'
| 'readReceiptSettingTitle'
| 'received'
@ -404,6 +406,7 @@ export type LocalizerKeys =
| 'sendMessage'
| 'sendRecoveryPhraseMessage'
| 'sendRecoveryPhraseTitle'
| 'sending'
| 'sent'
| 'sessionMessenger'
| 'set'

Loading…
Cancel
Save