Merge remote-tracking branch 'upstream/clearnet' into unstable

This commit is contained in:
Audric Ackermann 2023-07-04 11:46:10 +02:00
commit 594eee698b
46 changed files with 1278 additions and 813 deletions

View File

@ -3,6 +3,9 @@ components/**
dist/**
mnemonic_languages/**
# editor
.vscode/**
# TypeScript generated files
ts/**/*.js
**/ts/**/*.js

View File

@ -18,6 +18,7 @@ ts/test/automation/notes
node_modules/**
mnemonic_languages/**
playwright.config.js
.vscode/
# Managed by package manager (`yarn`/`npm`):
/package.json
@ -26,4 +27,4 @@ playwright.config.js
release/**
.nyc_output/
coverage/
stylesheets/dist/**
stylesheets/dist/**

View File

@ -45,6 +45,7 @@
"incomingError": "Error handling incoming message",
"media": "Media",
"mediaEmptyState": "No media",
"document": "Document",
"documents": "Documents",
"documentsEmptyState": "No documents",
"today": "Today",
@ -84,6 +85,7 @@
"you": "You",
"audioPermissionNeededTitle": "Microphone Access Required",
"audioPermissionNeeded": "You can enable microphone access under: Settings (Gear icon) => Privacy",
"image": "Image",
"audio": "Audio",
"video": "Video",
"photo": "Photo",

View File

@ -104,8 +104,8 @@
"ip2country": "1.0.1",
"libsession_util_nodejs": "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.20/libsession_util_nodejs-v0.1.20.tar.gz",
"libsodium-wrappers-sumo": "^0.7.9",
"linkify-it": "3.0.2",
"lodash": "^4.17.21",
"linkify-it": "^4.0.1",
"long": "^4.0.0",
"mic-recorder-to-mp3": "^2.2.2",
"moment": "^2.29.4",
@ -161,8 +161,8 @@
"@types/firstline": "^2.0.2",
"@types/fs-extra": "5.0.5",
"@types/libsodium-wrappers-sumo": "^0.7.5",
"@types/linkify-it": "2.0.3",
"@types/lodash": "^4.14.194",
"@types/linkify-it": "^3.0.2",
"@types/mocha": "5.0.0",
"@types/mustache": "^4.1.2",
"@types/node-fetch": "^2.5.7",

View File

@ -844,8 +844,8 @@
.module-image__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
z-index: 2;
@ -876,17 +876,6 @@
@include color-svg('../images/x-16.svg', var(--button-icon-stroke-color));
}
.module-attachments__rail {
margin-top: 12px;
margin-inline-start: 16px;
padding-inline-end: 16px;
overflow-x: scroll;
max-height: 142px;
white-space: nowrap;
overflow-y: hidden;
margin-bottom: 6px;
}
// Module: Staged Generic Attachment
.module-staged-generic-attachment {
@ -1032,22 +1021,6 @@
// Module: Staged Placeholder Attachment
.module-staged-placeholder-attachment {
margin: 1px;
border-radius: 4px;
border: 1px solid var(--border-color);
height: 120px;
width: 120px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
position: relative;
&:hover {
background-color: var(--background-secondary-color);
}
}
.module-staged-placeholder-attachment__plus-icon {
position: absolute;
left: 50%;
@ -1058,67 +1031,7 @@
height: 36px;
width: 36px;
@include color-svg('../images/plus-36.svg', var(--button-icon-stroke-color));
}
// Module: Staged Link Preview
.module-staged-link-preview {
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
min-height: 65px;
margin: var(--margins-xs);
}
.module-staged-link-preview--is-loading {
align-items: center;
justify-content: center;
}
.module-staged-link-preview__loading {
color: var(--text-primary-color);
font-size: 14px;
text-align: center;
flex-grow: 1;
flex-shrink: 1;
}
.module-staged-link-preview__icon-container {
margin-inline-end: 8px;
padding: var(--margins-sm);
}
.module-staged-link-preview__content {
margin-inline-end: 20px;
padding: var(--margins-sm);
}
.module-staged-link-preview__title {
font-weight: 500;
font-size: 14px;
line-height: 18px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.module-staged-link-preview__location {
margin-top: 4px;
font-size: var(--font-size-xs);
height: 16px;
letter-spacing: 0.25px;
text-transform: uppercase;
}
.module-staged-link-preview__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
height: 16px;
width: 16px;
@include color-svg('../images/x-16.svg', var(--button-icon-stroke-color));
@include color-svg('../images/plus-36.svg', var(--chat-buttons-icon-color));
}
// Module: Left Pane

View File

@ -1,238 +0,0 @@
// This is related to all quote logics
.module-quote {
position: relative;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
border-left-width: 4px;
border-left-style: solid;
/* Primary */
&__primary {
flex-grow: 1;
padding-inline-start: 8px;
padding-inline-end: 8px;
max-width: 100%;
}
&__primary__profile-name {
font-style: italic;
}
&__primary__type-label {
font-style: italic;
font-size: var(--font-size-sm);
line-height: 18px;
color: var(--message-bubbles-received-text-color);
border-color: var(--message-bubbles-received-text-color);
}
&__primary__author {
font-size: var(--font-size-sm);
font-weight: bold;
line-height: 18px;
margin-bottom: 5px;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--message-bubbles-received-text-color);
.module-contact-name {
font-weight: bold;
}
}
&__primary__text {
font-size: 14px;
line-height: 18px;
text-align: start;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
color: var(--message-bubbles-received-text-color);
a {
color: var(--message-bubbles-received-text-color);
}
}
&__primary__filename-label {
font-size: 12px;
}
/* Icons */
&__icon-container {
flex: initial;
min-width: 54px;
width: 54px;
max-height: 54px;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__inner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
&__circle-background {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
border-radius: 50%;
background-color: var(--chat-buttons-background-color);
&:hover {
background-color: var(--chat-buttons-background-hover-color);
}
}
&__icon {
width: 24px;
height: 24px;
&--file {
@include color-svg('../images/file.svg', var(--button-icon-stroke-color));
}
&--image {
@include color-svg('../images/image.svg', var(--button-icon-stroke-color));
}
&--microphone {
@include color-svg('../images/microphone.svg', var(--button-icon-stroke-color));
}
&--play {
@include color-svg('../images/play.svg', var(--chat-buttons-icon-color));
}
&--movie {
@include color-svg('../images/movie.svg', var(--button-icon-stroke-color));
}
}
}
/* Generic Files */
&__generic {
&-file {
display: flex;
flex-direction: row;
align-items: center;
}
&-file__icon {
background: url('../images/file-gradient.svg');
background-size: 75%;
background-repeat: no-repeat;
height: 28px;
width: 36px;
margin-inline-start: -4px;
margin-inline-end: -6px;
margin-bottom: 5px;
}
&-file__text {
font-size: 14px;
line-height: 18px;
color: var(--message-bubbles-received-text-color);
max-width: calc(100% - 26px);
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
/* Reference Warning */
&__reference {
&-warning {
height: 26px;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--message-link-preview-background-color);
padding-inline-start: 8px;
padding-inline-end: 8px;
margin-inline-end: 8px;
}
&-warning__icon {
height: 16px;
width: 16px;
@include color-svg('../images/broken-link.svg', var(--message-bubbles-received-text-color));
}
&-warning__text {
margin-inline-start: 6px;
color: var(--message-bubbles-received-text-color);
font-size: var(--font-size-sm);
line-height: 18px;
}
}
/* Misc */
&--no-click {
cursor: auto;
}
}
/* Outgoing messages */
.module-quote--outgoing {
color: var(--message-bubbles-sent-text-color);
.module-quote {
&__primary__type-label {
color: var(--message-bubbles-sent-text-color);
border-color: var(--message-bubbles-sent-text-color);
}
&__primary__author {
color: var(--message-bubbles-sent-text-color);
}
&__primary__text {
color: var(--message-bubbles-sent-text-color);
a {
color: var(--message-bubbles-sent-text-color);
}
}
&__generic {
&-file__text {
color: var(--message-bubbles-sent-text-color);
}
}
}
}
.module-quote-container {
margin-bottom: var(--margins-xs);
margin-top: var(--margins-xs);
min-width: 300px; // if the quoted content is small it doesn't look very good so we set a minimum
padding-right: var(--margins-xs);
/* This is not within the module-quote class so we handle it separately */
.module-quote__reference-warning--outgoing {
.module-quote__reference-warning__text {
color: var(--message-bubbles-sent-text-color);
}
.module-quote__reference-warning__icon {
@include color-svg('../images/broken-link.svg', var(--message-bubbles-sent-text-color));
}
}
}

View File

@ -26,7 +26,7 @@
display: inline-flex;
flex-direction: row;
align-items: flex-end;
max-width: 95%;
max-width: 100%;
@media (min-width: 1200px) {
max-width: 65%;

View File

@ -27,7 +27,6 @@
// /////////////////// //
@import 'modules';
@import 'session';
@import 'quote';
@import 'rtl';
// Separate screens

View File

@ -36,10 +36,10 @@ export const Emojify = (props: Props): JSX.Element => {
size = 1.1;
break;
case 'default':
size = 1.0;
break;
default:
size = 1.0;
}
return <span style={{ fontSize: `${size}rem`, userSelect: 'inherit' }}>{rendered}</span>;
// NOTE (Will): This should be em and not rem because we want to keep the inherited font size from the parent element and not the root
return <span style={{ fontSize: `${size}em`, userSelect: 'inherit' }}>{rendered}</span>;
};

View File

@ -10,56 +10,80 @@ import { Flex } from '../basic/Flex';
import { Image } from '../../../ts/components/conversation/Image';
// tslint:disable-next-line: no-submodule-imports
import useKey from 'react-use/lib/useKey';
import { getAbsoluteAttachmentPath } from '../../types/MessageAttachment';
import { GoogleChrome } from '../../util';
import { findAndFormatContact } from '../../models/message';
const QuotedMessageComposition = styled.div`
background-color: var(--background-secondary-color);
width: 100%;
padding-inline-end: var(--margins-md);
padding-inline-start: var(--margins-md);
padding-bottom: var(--margins-xs);
const QuotedMessageComposition = styled(Flex)`
border-top: 1px solid var(--border-color);
`;
const QuotedMessageCompositionReply = styled.div`
background: var(--message-bubbles-received-background-color);
border-radius: var(--margins-sm);
padding: var(--margins-xs);
margin: var(--margins-xs);
const QuotedMessageCompositionReply = styled(Flex)<{ hasAttachments: boolean }>`
${props => !props.hasAttachments && 'border-left: 3px solid var(--primary-color);'}
`;
const Subtle = styled.div`
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
display: -webkit-box;
color: var(--text-primary-color);
`;
const ReplyingTo = styled.div`
color: var(--text-primary-color);
const StyledImage = styled.div`
div {
border-radius: 4px;
overflow: hidden;
}
`;
const StyledText = styled(Flex)`
margin: 0 0 0 var(--margins-sm);
p {
font-weight: bold;
margin: 0;
}
`;
function checkHasAttachments(attachments: Array<any> | undefined) {
const hasAttachments = attachments && attachments.length > 0 && attachments[0];
// NOTE could be a video as well which will load a thumbnail
const firstImageLikeAttachment =
hasAttachments && attachments[0].contentType !== AUDIO_MP3 && attachments[0].thumbnail
? attachments[0]
: undefined;
return { hasAttachments, firstImageLikeAttachment };
}
function renderSubtitleText(
quoteText: string | undefined,
hasAudioAttachment: boolean,
isGenericFile: boolean,
isVideo: boolean,
isImage: boolean
): string | null {
return quoteText && quoteText !== ''
? quoteText
: hasAudioAttachment
? window.i18n('audio')
: isGenericFile
? window.i18n('document')
: isVideo
? window.i18n('video')
: isImage
? window.i18n('image')
: null;
}
export const SessionQuotedMessageComposition = () => {
const dispatch = useDispatch();
const quotedMessageProps = useSelector(getQuotedMessage);
const dispatch = useDispatch();
const { text: body, attachments } = quotedMessageProps || {};
const hasAttachments = attachments && attachments.length > 0;
let hasImageAttachment = false;
let firstImageAttachment;
// we have to handle the case we are trying to reply to an audio message
if (attachments?.length && attachments[0].contentType !== AUDIO_MP3 && attachments[0].thumbnail) {
firstImageAttachment = attachments[0];
hasImageAttachment = true;
}
const hasAudioAttachment =
hasAttachments && attachments && attachments.length > 0 && isAudio(attachments);
const { author, attachments, text: quoteText } = quotedMessageProps || {};
const removeQuotedMessage = () => {
dispatch(quoteMessage(undefined));
@ -67,40 +91,84 @@ export const SessionQuotedMessageComposition = () => {
useKey('Escape', removeQuotedMessage, undefined, []);
if (!quotedMessageProps?.id) {
if (!author || !quotedMessageProps?.id) {
return null;
}
const contact = findAndFormatContact(author);
const authorName = contact?.profileName || contact?.name || author || window.i18n('unknown');
const { hasAttachments, firstImageLikeAttachment } = checkHasAttachments(attachments);
const isImage = Boolean(
firstImageLikeAttachment &&
GoogleChrome.isImageTypeSupported(firstImageLikeAttachment.contentType)
);
const isVideo = Boolean(
firstImageLikeAttachment &&
GoogleChrome.isVideoTypeSupported(firstImageLikeAttachment.contentType)
);
const hasAudioAttachment = Boolean(hasAttachments && isAudio(attachments));
const isGenericFile = !hasAudioAttachment && !isVideo && !isImage;
const subtitleText = renderSubtitleText(
quoteText,
hasAudioAttachment,
isGenericFile,
isVideo,
isImage
);
return (
<QuotedMessageComposition>
<Flex
<QuotedMessageComposition
container={true}
justifyContent="space-between"
alignItems="center"
width={'100%'}
flexGrow={1}
padding={'var(--margins-md)'}
>
<QuotedMessageCompositionReply
container={true}
justifyContent="space-between"
flexGrow={1}
margin={'0 var(--margins-xs) var(--margins-xs)'}
padding={'var(--margins-xs)'}
justifyContent="flex-start"
alignItems={'center'}
hasAttachments={hasAttachments}
>
<ReplyingTo>{window.i18n('replyingToMessage')}</ReplyingTo>
<SessionIconButton iconType="exit" iconSize="small" onClick={removeQuotedMessage} />
</Flex>
<QuotedMessageCompositionReply>
<Flex container={true} justifyContent="space-between" margin={'var(--margins-xs)'}>
<Subtle>{(hasAttachments && window.i18n('mediaMessage')) || body}</Subtle>
{hasImageAttachment && (
<Image
alt={getAlt(firstImageAttachment)}
attachment={firstImageAttachment}
height={100}
width={100}
url={firstImageAttachment.thumbnail.objectUrl}
softCorners={false}
/>
)}
{hasAudioAttachment && <SessionIcon iconType="microphone" iconSize="huge" />}
</Flex>
{hasAttachments && (
<StyledImage>
{firstImageLikeAttachment ? (
<Image
alt={getAlt(firstImageLikeAttachment)}
attachment={firstImageLikeAttachment}
height={100}
width={100}
url={getAbsoluteAttachmentPath((firstImageLikeAttachment as any).thumbnail.path)}
softCorners={true}
/>
) : hasAudioAttachment ? (
<div style={{ margin: '0 var(--margins-xs) 0 0' }}>
<SessionIcon iconType="microphone" iconSize="huge" />
</div>
) : null}
</StyledImage>
)}
<StyledText
container={true}
flexDirection="column"
justifyContent={'center'}
alignItems={'flex-start'}
>
<p>{authorName}</p>
{subtitleText && <Subtle>{subtitleText}</Subtle>}
</StyledText>
</QuotedMessageCompositionReply>
<SessionIconButton
iconType="exit"
iconColor="var(--chat-buttons-icon-color)"
iconSize="small"
onClick={removeQuotedMessage}
margin={'0 var(--margins-sm) 0 0'}
aria-label={window.i18n('close')}
/>
</QuotedMessageComposition>
);
};

View File

@ -1,14 +1,15 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import {
removeAllStagedAttachmentsInConversation,
removeStagedAttachmentInConversation,
} from '../../state/ducks/stagedAttachments';
import { useSelectedConversationKey } from '../../state/selectors/selectedConversation';
import {
areAllAttachmentsVisual,
AttachmentType,
areAllAttachmentsVisual,
getUrl,
isVideoAttachment,
} from '../../types/Attachment';
@ -26,6 +27,17 @@ type Props = {
const IMAGE_WIDTH = 120;
const IMAGE_HEIGHT = 120;
const StyledRail = styled.div`
margin-top: 12px;
margin-inline-start: 16px;
padding-inline-end: 16px;
overflow-x: scroll;
max-height: 142px;
white-space: nowrap;
overflow-y: hidden;
margin-bottom: 6px;
`;
export const StagedAttachmentList = (props: Props) => {
const { attachments, onAddAttachment, onClickAttachment } = props;
@ -63,7 +75,7 @@ export const StagedAttachmentList = (props: Props) => {
/>
</div>
) : null}
<div className="module-attachments__rail">
<StyledRail>
{(attachments || []).map((attachment, index) => {
const { contentType } = attachment;
if (isImageTypeSupported(contentType) || isVideoTypeSupported(contentType)) {
@ -103,7 +115,7 @@ export const StagedAttachmentList = (props: Props) => {
);
})}
{allVisualAttachments ? <StagedPlaceholderAttachment onClick={onAddAttachment} /> : null}
</div>
</StyledRail>
</div>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import classNames from 'classnames';
import { Image } from './Image';
@ -7,6 +6,9 @@ import { SessionSpinner } from '../basic/SessionSpinner';
import { StagedLinkPreviewImage } from './composition/CompositionBox';
import { isImage } from '../../types/MIME';
import { fromArrayBufferToBase64 } from '../../session/utils/String';
import styled from 'styled-components';
import { Flex } from '../basic/Flex';
import { SessionIconButton } from '../icon';
type Props = {
isLoaded: boolean;
@ -18,10 +20,37 @@ type Props = {
onClose: (url: string) => void;
};
// Note Similar to QuotedMessageComposition
const StyledStagedLinkPreview = styled(Flex)`
position: relative;
/* Same height as a loaded Image Attachment */
min-height: 132px;
border-top: 1px solid var(--border-color);
`;
const StyledImage = styled.div`
div {
border-radius: 4px;
overflow: hidden;
}
`;
const StyledText = styled(Flex)`
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
display: -webkit-box;
font-weight: bold;
margin: 0 0 0 var(--margins-sm);
`;
export const StagedLinkPreview = (props: Props) => {
const { isLoaded, onClose, title, image, domain, url } = props;
const isContentTypeImage = image && isImage(image.contentType);
if (isLoaded && !(title && domain)) {
return null;
}
@ -33,42 +62,47 @@ export const StagedLinkPreview = (props: Props) => {
: '';
return (
<div
className={classNames(
'module-staged-link-preview',
isLoading ? 'module-staged-link-preview--is-loading' : null
)}
<StyledStagedLinkPreview
container={true}
justifyContent={isLoading ? 'center' : 'space-between'}
alignItems="center"
width={'100%'}
padding={'var(--margins-md)'}
>
{isLoading ? <SessionSpinner loading={isLoading} /> : null}
{isLoaded && image && isContentTypeImage ? (
<div className="module-staged-link-preview__icon-container">
<Image
alt={window.i18n('stagedPreviewThumbnail', [domain || ''])}
softCorners={true}
height={72}
width={72}
url={dataToRender}
attachment={image as any}
/>
</div>
) : null}
{isLoaded ? (
<div className="module-staged-link-preview__content">
<div className="module-staged-link-preview__title">{title}</div>
<div className="module-staged-link-preview__footer">
<div className="module-staged-link-preview__location">{domain}</div>
</div>
</div>
) : null}
<button
type="button"
className="module-staged-link-preview__close-button"
<Flex
container={true}
justifyContent={isLoading ? 'center' : 'flex-start'}
alignItems={'center'}
>
{isLoading ? <SessionSpinner loading={isLoading} /> : null}
{isLoaded && image && isContentTypeImage ? (
<StyledImage>
<Image
alt={window.i18n('stagedPreviewThumbnail', [domain || ''])}
attachment={image as any}
height={100}
width={100}
url={dataToRender}
softCorners={true}
/>
</StyledImage>
) : null}
{isLoaded ? <StyledText>{title}</StyledText> : null}
</Flex>
<SessionIconButton
iconType="exit"
iconColor="var(--chat-buttons-icon-color)"
iconSize="small"
onClick={() => {
onClose(url || '');
}}
margin={'0 var(--margins-sm) 0 0'}
aria-label={window.i18n('close')}
style={{
position: isLoading ? 'absolute' : undefined,
right: isLoading ? 'var(--margins-sm)' : undefined,
}}
/>
</div>
</StyledStagedLinkPreview>
);
};

View File

@ -1,17 +1,33 @@
import React from 'react';
import React, { MouseEvent } from 'react';
import styled from 'styled-components';
// tslint:disable: react-unused-props-and-state
interface Props {
onClick: () => void;
onClick: (e: MouseEvent<HTMLDivElement>) => void;
}
export class StagedPlaceholderAttachment extends React.Component<Props> {
public render() {
const { onClick } = this.props;
const StyledStagedPlaceholderAttachment = styled.div`
margin: 1px var(--margins-sm);
border-radius: var(--border-radius-message-box);
border: 1px solid var(--border-color);
height: 120px;
width: 120px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
position: relative;
return (
<div className="module-staged-placeholder-attachment" role="button" onClick={onClick}>
<div className="module-staged-placeholder-attachment__plus-icon" />
</div>
);
&:hover {
background-color: var(--background-secondary-color);
}
}
`;
export const StagedPlaceholderAttachment = (props: Props) => {
const { onClick } = props;
return (
<StyledStagedPlaceholderAttachment role="button" onClick={onClick}>
<div className="module-staged-placeholder-attachment__plus-icon" />
</StyledStagedPlaceholderAttachment>
);
};

View File

@ -63,7 +63,7 @@ import { SettingsKey } from '../../../data/settings-key';
export interface ReplyingToMessageProps {
convoId: string;
id: string;
id: string; // this is the quoted message timestamp
author: string;
timestamp: number;
text?: string;

View File

@ -37,10 +37,10 @@ export type MessageAvatarSelectorProps = Pick<
'sender' | 'isSenderAdmin' | 'lastMessageOfSeries'
>;
type Props = { messageId: string; noAvatar: boolean };
type Props = { messageId: string; hideAvatar: boolean; isPrivate: boolean };
export const MessageAvatar = (props: Props) => {
const { messageId, noAvatar } = props;
const { messageId, hideAvatar, isPrivate } = props;
const dispatch = useDispatch();
const selectedConvoKey = useSelectedConversationKey();
@ -54,7 +54,7 @@ export const MessageAvatar = (props: Props) => {
const lastMessageOfSeries = useLastMessageOfSeries(messageId);
const isSenderAdmin = useMessageSenderIsAdmin(messageId);
if (noAvatar || !sender) {
if (!sender) {
return null;
}
@ -117,12 +117,21 @@ export const MessageAvatar = (props: Props) => {
);
}, [userName, sender, isPublic, authorAvatarPath, selectedConvoKey]);
if (isPrivate) {
return null;
}
if (!lastMessageOfSeries) {
return <div style={{ marginInlineEnd: '60px' }} key={`msg-avatar-${sender}`} />;
}
return (
<StyledAvatar key={`msg-avatar-${sender}`}>
<StyledAvatar
key={`msg-avatar-${sender}`}
style={{
visibility: hideAvatar ? 'hidden' : undefined,
}}
>
<Avatar size={AvatarSize.S} onAvatarClick={onMessageAvatarClick} pubkey={sender} />
{isSenderAdmin && <CrownIcon />}
</StyledAvatar>

View File

@ -4,14 +4,14 @@ import moment from 'moment';
import React, { createContext, useCallback, useContext, useLayoutEffect, useState } from 'react';
import { InView } from 'react-intersection-observer';
import { useSelector } from 'react-redux';
import styled, { css } from 'styled-components';
import styled, { css, keyframes } from 'styled-components';
import { MessageModelType, MessageRenderingProps } from '../../../../models/messageType';
import { useMessageIsDeleted } from '../../../../state/selectors';
import {
getMessageContentSelectorProps,
getQuotedMessageToAnimate,
getShouldHighlightMessage,
} from '../../../../state/selectors/conversations';
import { useMessageIsDeleted } from '../../../../state/selectors';
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
import { MessageAttachment } from './MessageAttachment';
import { MessageLinkPreview } from './MessageLinkPreview';
@ -44,19 +44,7 @@ function onClickOnMessageInnerContainer(event: React.MouseEvent<HTMLDivElement>)
const StyledMessageContent = styled.div``;
const StyledMessageOpaqueContent = styled.div<{
messageDirection: MessageModelType;
highlight: boolean;
}>`
background: ${props =>
props.messageDirection === 'incoming'
? 'var(--message-bubbles-received-background-color)'
: 'var(--message-bubbles-sent-background-color)'};
align-self: ${props => (props.messageDirection === 'incoming' ? 'flex-start' : 'flex-end')};
padding: var(--padding-message-content);
border-radius: var(--border-radius-message-box);
@keyframes highlight {
const opacityAnimation = keyframes`
0% {
opacity: 1;
}
@ -72,19 +60,29 @@ const StyledMessageOpaqueContent = styled.div<{
100% {
opacity: 1;
}
}
`;
${props => {
return (
props.highlight &&
css`
animation-name: highlight;
animation-timing-function: linear;
animation-duration: 1s;
border-radius: 'var(--border-radius-message-box)';
`
);
}}
const StyledMessageHighlighter = styled.div<{
highlight: boolean;
}>`
${props =>
props.highlight &&
css`
animation: ${opacityAnimation} 1s linear;
`}
`;
const StyledMessageOpaqueContent = styled(StyledMessageHighlighter)<{
messageDirection: MessageModelType;
highlight: boolean;
}>`
background: ${props =>
props.messageDirection === 'incoming'
? 'var(--message-bubbles-received-background-color)'
: 'var(--message-bubbles-sent-background-color)'};
align-self: ${props => (props.messageDirection === 'incoming' ? 'flex-start' : 'flex-end')};
padding: var(--padding-message-content);
border-radius: var(--border-radius-message-box);
`;
export const IsMessageVisibleContext = createContext(false);
@ -148,9 +146,9 @@ export const MessageContent = (props: Props) => {
return null;
}
const { direction, text, timestamp, serverTimestamp, previews } = contentProps;
const { direction, text, timestamp, serverTimestamp, previews, quote } = contentProps;
const hasContentAfterAttachmentAndQuote = !isEmpty(previews) || !isEmpty(text);
const hasContentBeforeAttachment = !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text);
const toolTipTitle = moment(serverTimestamp || timestamp).format('llll');
@ -174,7 +172,7 @@ export const MessageContent = (props: Props) => {
}}
>
<IsMessageVisibleContext.Provider value={isMessageVisible}>
{hasContentAfterAttachmentAndQuote && (
{hasContentBeforeAttachment && (
<StyledMessageOpaqueContent messageDirection={direction} highlight={highlight}>
{!isDeleted && (
<>
@ -189,11 +187,13 @@ export const MessageContent = (props: Props) => {
</StyledMessageOpaqueContent>
)}
{!isDeleted && (
<MessageAttachment
messageId={props.messageId}
imageBroken={imageBroken}
handleImageError={handleImageError}
/>
<StyledMessageHighlighter highlight={highlight}>
<MessageAttachment
messageId={props.messageId}
imageBroken={imageBroken}
handleImageError={handleImageError}
/>
</StyledMessageHighlighter>
)}
</IsMessageVisibleContext.Provider>
</InView>

View File

@ -94,7 +94,9 @@ export const MessageContentWithStatuses = (props: Props) => {
}
const { conversationType, direction, isDeleted } = contentProps;
const isIncoming = direction === 'incoming';
const noAvatar = conversationType !== 'group' || direction === 'outgoing';
const isPrivate = conversationType === 'private';
const hideAvatar = isPrivate || direction === 'outgoing';
const [popupReaction, setPopupReaction] = useState('');
@ -120,7 +122,7 @@ export const MessageContentWithStatuses = (props: Props) => {
onDoubleClickCapture={onDoubleClickReplyToMessage}
data-testid={dataTestId}
>
<MessageAvatar messageId={messageId} noAvatar={noAvatar} />
<MessageAvatar messageId={messageId} hideAvatar={hideAvatar} isPrivate={isPrivate} />
<MessageStatus
dataTestId="msg-status-incoming"
messageId={messageId}
@ -150,7 +152,7 @@ export const MessageContentWithStatuses = (props: Props) => {
popupReaction={popupReaction}
setPopupReaction={setPopupReaction}
onPopupClick={handlePopupClick}
noAvatar={noAvatar}
noAvatar={hideAvatar}
/>
)}
</StyledMessageContentContainer>

View File

@ -1,18 +1,18 @@
import { isEmpty, toNumber } from 'lodash';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import _ from 'lodash';
import { Data } from '../../../../data/data';
import { MessageRenderingProps } from '../../../../models/messageType';
import { PubKey } from '../../../../session/types';
import { ToastUtils } from '../../../../session/utils';
import { openConversationToSpecificMessage } from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { useMessageDirection } from '../../../../state/selectors';
import {
getMessageQuoteProps,
isMessageDetailView,
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { Quote } from './Quote';
import { ToastUtils } from '../../../../session/utils';
import { Data } from '../../../../data/data';
import { MessageModel } from '../../../../models/message';
import { useMessageDirection, useMessageQuote } from '../../../../state/selectors';
import { Quote } from './quote/Quote';
// tslint:disable: use-simple-attributes
@ -23,17 +23,32 @@ type Props = {
export type MessageQuoteSelectorProps = Pick<MessageRenderingProps, 'quote' | 'direction'>;
export const MessageQuote = (props: Props) => {
const quote = useMessageQuote(props.messageId);
const selected = useSelector((state: StateType) => getMessageQuoteProps(state, props.messageId));
const direction = useMessageDirection(props.messageId);
const multiSelectMode = useSelector(isMessageSelectionMode);
const isMessageDetailViewMode = useSelector(isMessageDetailView);
if (!selected || isEmpty(selected)) {
return null;
}
const quote = selected ? selected.quote : undefined;
if (!quote || isEmpty(quote)) {
return null;
}
const quoteNotFound = Boolean(
quote.referencedMessageNotFound || !quote?.author || !quote.id || !quote.convoId
);
const onQuoteClick = useCallback(
async (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (!quote) {
ToastUtils.pushOriginalNotFound();
window.log.warn('onQuoteClick: quote not valid');
return;
}
@ -43,59 +58,55 @@ export const MessageQuote = (props: Props) => {
return;
}
const {
referencedMessageNotFound,
messageId: quotedMessageSentAt,
sender: quoteAuthor,
} = quote;
let conversationKey = String(quote.convoId);
let messageIdToNavigateTo = String(quote.id);
let quoteNotFoundInDB = false;
// If the quote is not found in memory, we try to find it in the DB
if (quoteNotFound && quote.id && quote.author) {
const quotedMessagesCollection = await Data.getMessagesBySenderAndSentAt([
{ timestamp: toNumber(quote.id), source: quote.author },
]);
if (quotedMessagesCollection?.length) {
const quotedMessage = quotedMessagesCollection.at(0);
// If found, we navigate to the quoted message which also refreshes the message quote component
if (quotedMessage) {
conversationKey = String(quotedMessage.get('conversationId'));
messageIdToNavigateTo = String(quotedMessage.id);
} else {
quoteNotFoundInDB = true;
}
} else {
quoteNotFoundInDB = true;
}
}
// For simplicity's sake, we show the 'not found' toast no matter what if we were
// not able to find the referenced message when the quote was received.
if (referencedMessageNotFound || !quotedMessageSentAt || !quoteAuthor) {
// not able to find the referenced message when the quote was received or if the conversation no longer exists.
if (quoteNotFoundInDB) {
ToastUtils.pushOriginalNotFound();
return;
}
const collection = await Data.getMessagesBySentAt(_.toNumber(quotedMessageSentAt));
const foundInDb = collection.find((item: MessageModel) => {
const messageAuthor = item.get('source');
return Boolean(messageAuthor && quoteAuthor === messageAuthor);
});
if (!foundInDb) {
ToastUtils.pushOriginalNotFound();
return;
}
void openConversationToSpecificMessage({
conversationKey: foundInDb.get('conversationId'),
messageIdToNavigateTo: foundInDb.get('id'),
conversationKey,
messageIdToNavigateTo,
shouldHighlightMessage: true,
});
},
[quote, multiSelectMode, props.messageId]
[isMessageDetailViewMode, multiSelectMode, quote, quoteNotFound]
);
if (!props.messageId) {
return null;
}
if (!quote || !quote.sender || !quote.messageId) {
return null;
}
const shortenedPubkey = PubKey.shorten(quote.sender);
const displayedPubkey = quote.authorProfileName ? shortenedPubkey : quote.sender;
return (
<Quote
onClick={onQuoteClick}
text={quote.text || ''}
attachment={quote.attachment}
text={quote?.text}
attachment={quote?.attachment}
isIncoming={direction === 'incoming'}
sender={displayedPubkey}
authorProfileName={quote.authorProfileName}
authorName={quote.authorName}
referencedMessageNotFound={quote.referencedMessageNotFound || false}
isFromMe={quote.isFromMe || false}
author={quote.author}
referencedMessageNotFound={quoteNotFound}
isFromMe={Boolean(quote.isFromMe)}
/>
);
};

View File

@ -0,0 +1,104 @@
import React, { MouseEvent, useState } from 'react';
import * as MIME from '../../../../../types/MIME';
import { QuoteAuthor } from './QuoteAuthor';
import { QuoteText } from './QuoteText';
import { QuoteIconContainer } from './QuoteIconContainer';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
const StyledQuoteContainer = styled.div`
min-width: 300px; // if the quoted content is small it doesn't look very good so we set a minimum
padding-right: var(--margins-xs);
`;
const StyledQuote = styled.div<{
hasAttachment: boolean;
isIncoming: boolean;
onClick: ((e: MouseEvent<HTMLDivElement>) => void) | undefined;
}>`
position: relative;
display: flex;
flex-direction: row;
align-items: stretch;
margin: ${props => (props.hasAttachment ? 'var(--margins-md)' : 'var(--margins-xs)')} 0;
${props => !props.hasAttachment && 'border-left: 4px solid;'}
border-color: ${props =>
props.isIncoming
? 'var(--message-bubbles-received-text-color)'
: 'var(--message-bubbles-sent-text-color)'};
cursor: ${props => (props.onClick ? 'pointer' : 'auto')};
`;
const StyledQuoteTextContent = styled.div`
flex-grow: 1;
padding-inline-start: 10px;
padding-inline-end: 10px;
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
`;
export type QuoteProps = {
author: string;
isFromMe: boolean;
isIncoming: boolean;
referencedMessageNotFound: boolean;
text?: string;
attachment?: QuotedAttachmentType;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
};
export interface QuotedAttachmentThumbnailType {
contentType: MIME.MIMEType;
/** Not included in protobuf, and is loaded asynchronously */
objectUrl?: string;
}
export interface QuotedAttachmentType {
contentType: MIME.MIMEType;
fileName: string;
/** Not included in protobuf */
isVoiceMessage: boolean;
thumbnail?: QuotedAttachmentThumbnailType;
}
export const Quote = (props: QuoteProps) => {
const { isIncoming, attachment, text, referencedMessageNotFound, onClick } = props;
const [imageBroken, setImageBroken] = useState(false);
const handleImageErrorBound = () => {
setImageBroken(true);
};
return (
<StyledQuoteContainer>
<StyledQuote
hasAttachment={Boolean(!isEmpty(attachment))}
isIncoming={isIncoming}
onClick={onClick}
>
<QuoteIconContainer
attachment={attachment}
handleImageErrorBound={handleImageErrorBound}
imageBroken={imageBroken}
referencedMessageNotFound={referencedMessageNotFound}
/>
<StyledQuoteTextContent>
<QuoteAuthor author={props.author} isIncoming={isIncoming} />
<QuoteText
isIncoming={isIncoming}
text={text}
attachment={attachment}
referencedMessageNotFound={referencedMessageNotFound}
/>
</StyledQuoteTextContent>
</StyledQuote>
</StyledQuoteContainer>
);
};

View File

@ -0,0 +1,48 @@
import React from 'react';
import styled from 'styled-components';
import { useQuoteAuthorName } from '../../../../../hooks/useParamSelector';
import { PubKey } from '../../../../../session/types';
import { useSelectedIsPublic } from '../../../../../state/selectors/selectedConversation';
import { ContactName } from '../../../ContactName';
import { QuoteProps } from './Quote';
const StyledQuoteAuthor = styled.div<{ isIncoming: boolean }>`
color: ${props =>
props.isIncoming
? 'var(--message-bubbles-received-text-color)'
: 'var(--message-bubbles-sent-text-color)'};
font-size: var(--font-size-md);
font-weight: bold;
line-height: 18px;
margin-bottom: 2px;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.module-contact-name {
font-weight: bold;
}
`;
type QuoteAuthorProps = Pick<QuoteProps, 'author' | 'isIncoming'>;
export const QuoteAuthor = (props: QuoteAuthorProps) => {
const { author, isIncoming } = props;
const isPublic = useSelectedIsPublic();
const authorName = useQuoteAuthorName(author);
if (!author || !authorName) {
return null;
}
return (
<StyledQuoteAuthor isIncoming={isIncoming}>
<ContactName
pubkey={PubKey.shorten(author)}
name={authorName}
compact={true}
shouldShowPubkey={Boolean(authorName && isPublic)}
/>
</StyledQuoteAuthor>
);
};

View File

@ -0,0 +1,137 @@
import React from 'react';
import { QuotedAttachmentThumbnailType, QuoteProps } from './Quote';
import { GoogleChrome } from '../../../../../util';
import { MIME } from '../../../../../types';
import { isEmpty, noop } from 'lodash';
import { QuoteImage } from './QuoteImage';
import styled from 'styled-components';
import { icons, SessionIconType } from '../../../../icon';
function getObjectUrl(thumbnail: QuotedAttachmentThumbnailType | undefined): string | undefined {
if (thumbnail && thumbnail.objectUrl) {
return thumbnail.objectUrl;
}
return;
}
const StyledQuoteIconContainer = styled.div`
flex: initial;
min-width: 54px;
width: 54px;
max-height: 54px;
position: relative;
`;
const StyledQuoteIcon = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
`;
const StyledQuoteIconBackground = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 54px;
width: 54px;
border-radius: var(--margins-sm);
background-color: var(--message-link-preview-background-color);
&:hover {
background-color: var(--message-link-preview-background-color);
}
svg {
width: 29px;
height: 29px;
fill: currentColor;
}
`;
type QuoteIconTypes = Extract<SessionIconType, 'file' | 'image' | 'play' | 'movie' | 'microphone'>;
type QuoteIconProps = {
icon: QuoteIconTypes;
};
export const QuoteIcon = (props: QuoteIconProps) => {
const { icon } = props;
const iconProps = icons[icon];
return (
<StyledQuoteIconContainer>
<StyledQuoteIcon>
<StyledQuoteIconBackground>
<svg viewBox={iconProps.viewBox}>
<path d={iconProps.path} />
</svg>
</StyledQuoteIconBackground>
</StyledQuoteIcon>
</StyledQuoteIconContainer>
);
};
export const QuoteIconContainer = (
props: Pick<QuoteProps, 'attachment' | 'referencedMessageNotFound'> & {
handleImageErrorBound: () => void;
imageBroken: boolean;
}
) => {
const { attachment, imageBroken, handleImageErrorBound, referencedMessageNotFound } = props;
if (referencedMessageNotFound || !attachment || isEmpty(attachment)) {
return null;
}
const { contentType, thumbnail } = attachment;
const isGenericFile =
!GoogleChrome.isVideoTypeSupported(contentType) &&
!GoogleChrome.isImageTypeSupported(contentType) &&
!MIME.isAudio(contentType);
if (isGenericFile) {
return <QuoteIcon icon="file" />;
}
const objectUrl = getObjectUrl(thumbnail);
if (objectUrl) {
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return (
<QuoteImage
url={objectUrl}
contentType={MIME.IMAGE_JPEG}
showPlayButton={true}
imageBroken={imageBroken}
handleImageErrorBound={noop}
/>
);
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return (
<QuoteImage
url={objectUrl}
contentType={contentType}
imageBroken={imageBroken}
handleImageErrorBound={handleImageErrorBound}
/>
);
}
}
if (MIME.isAudio(contentType)) {
return <QuoteIcon icon="microphone" />;
}
return null;
};

View File

@ -0,0 +1,95 @@
import React from 'react';
import { useDisableDrag } from '../../../../../hooks/useDisableDrag';
import { useEncryptedFileFetch } from '../../../../../hooks/useEncryptedFileFetch';
import styled from 'styled-components';
import { icons } from '../../../../icon';
import { isEmpty } from 'lodash';
import { QuoteIcon } from './QuoteIconContainer';
const StyledQuoteImage = styled.div`
flex: initial;
min-width: 54px;
width: 54px;
max-height: 54px;
position: relative;
border-radius: 4px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
const StyledPlayButton = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
div {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
border-radius: 50%;
background-color: var(--chat-buttons-background-color);
padding-left: 3px;
&:hover {
background-color: var(--chat-buttons-background-hover-color);
}
}
svg {
width: 14px;
height: 14px;
fill: var(--chat-buttons-icon-color);
}
`;
export const QuoteImage = (props: {
url: string;
contentType: string;
showPlayButton?: boolean;
imageBroken: boolean;
handleImageErrorBound: () => void;
}) => {
const { url, contentType, showPlayButton, imageBroken, handleImageErrorBound } = props;
const disableDrag = useDisableDrag();
const { loading, urlToLoad } = useEncryptedFileFetch(url, contentType, false);
const srcData = !loading ? urlToLoad : '';
return !isEmpty(srcData) && !imageBroken ? (
<StyledQuoteImage>
<img
src={srcData}
alt={window.i18n('quoteThumbnailAlt')}
onDragStart={disableDrag}
onError={handleImageErrorBound}
/>
{showPlayButton && (
<StyledPlayButton>
<div>
<svg viewBox={icons.play.viewBox}>
<path d={icons.play.path} />
</svg>
</div>
</StyledPlayButton>
)}
</StyledQuoteImage>
) : (
<QuoteIcon icon={showPlayButton ? 'movie' : 'image'} />
);
};

View File

@ -0,0 +1,85 @@
import { isEmpty } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { useSelectedIsGroup } from '../../../../../state/selectors/selectedConversation';
import { MIME } from '../../../../../types';
import { GoogleChrome } from '../../../../../util';
import { MessageBody } from '../MessageBody';
import { QuoteProps } from './Quote';
const StyledQuoteText = styled.div<{ isIncoming: boolean }>`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-size: 15px;
line-height: 18px;
text-align: start;
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
color: ${props =>
props.isIncoming
? 'var(--message-bubbles-received-text-color)'
: 'var(--message-bubbles-sent-text-color)'};
a {
color: ${props =>
props.isIncoming
? 'var(--color-received-message-text)'
: 'var(--message-bubbles-sent-text-color)'};
}
`;
function getTypeLabel({
contentType,
isVoiceMessage,
}: {
contentType: MIME.MIMEType;
isVoiceMessage: boolean;
}): string | undefined {
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return window.i18n('video');
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return window.i18n('image');
}
if (MIME.isAudio(contentType) && isVoiceMessage) {
return window.i18n('voiceMessage');
}
if (MIME.isAudio(contentType)) {
return window.i18n('audio');
}
return window.i18n('document');
}
export const QuoteText = (
props: Pick<QuoteProps, 'text' | 'attachment' | 'isIncoming' | 'referencedMessageNotFound'>
) => {
const { text, attachment, isIncoming, referencedMessageNotFound } = props;
const isGroup = useSelectedIsGroup();
if (!referencedMessageNotFound && attachment && !isEmpty(attachment)) {
const { contentType, isVoiceMessage } = attachment;
const typeLabel = getTypeLabel({ contentType, isVoiceMessage });
if (typeLabel && !text) {
return <div>{typeLabel}</div>;
}
}
return (
<StyledQuoteText isIncoming={isIncoming} dir="auto">
<MessageBody
text={text || window.i18n('originalMessageNotFound')}
disableLinks={true}
disableJumbomoji={true}
isGroup={isGroup}
/>
</StyledQuoteText>
);
};

View File

@ -49,7 +49,9 @@ export const CallNotification = (props: PropsForCallNotification) => {
nickname || displayNameInProfile || (selectedConvoId && PubKey.shorten(selectedConvoId));
const styleItem = style[notificationType];
const notificationText = window.i18n(styleItem.notificationTextKey, [displayName || 'Unknown']);
const notificationText = window.i18n(styleItem.notificationTextKey, [
displayName || window.i18n('unknown'),
]);
if (!window.i18n(styleItem.notificationTextKey)) {
throw new Error(`invalid i18n key ${styleItem.notificationTextKey}`);
}

View File

@ -5,6 +5,7 @@ import { Data } from '../../../../data/data';
import { PubKey } from '../../../../session/types/PubKey';
import { isDarkTheme } from '../../../../state/selectors/theme';
import { nativeEmojiData } from '../../../../util/emoji';
import { findAndFormatContact } from '../../../../models/message';
export type TipPosition = 'center' | 'left' | 'right';
@ -78,7 +79,7 @@ const generateContactsString = async (
if (message) {
let meIndex = -1;
results = senders.map((sender, index) => {
const contact = message.findAndFormatContact(sender);
const contact = findAndFormatContact(sender);
if (contact.isMe) {
meIndex = index;
}

View File

@ -26,6 +26,7 @@ import { ContactName } from '../conversation/ContactName';
import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
import { SessionIconButton } from '../icon';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { findAndFormatContact } from '../../models/message';
const StyledReactListContainer = styled(Flex)`
width: 376px;
@ -103,7 +104,7 @@ const ReactionSenders = (props: ReactionSendersProps) => {
const message = await Data.getMessageById(messageId);
if (message) {
handleClose();
const contact = message.findAndFormatContact(sender);
const contact = findAndFormatContact(sender);
dispatch(
updateUserDetailsModal({
conversationId: sender,

View File

@ -82,7 +82,7 @@ export class UpdateGroupNameDialog extends React.Component<Props, State> {
const okText = window.i18n('ok');
const cancelText = window.i18n('cancel');
const titleText = window.i18n('updateGroupDialogTitle', [
this.convo.getRealSessionUsername() || 'Unknown',
this.convo.getRealSessionUsername() || window.i18n('unknown'),
]);
const errorMsg = this.state.errorMessage;

View File

@ -30,12 +30,14 @@ export type SessionIconType =
| 'gear'
| 'group'
| 'hangup'
| 'image'
| 'info'
| 'link'
| 'messageRequest'
| 'microphone'
| 'microphoneFull'
| 'moon'
| 'movie'
| 'mute'
| 'oxen'
| 'externalLink'
@ -268,6 +270,12 @@ export const icons = {
viewBox: '0 0 1000 1000',
ratio: 1,
},
image: {
path:
'M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z',
viewBox: '0 0 24 24',
ratio: 1,
},
info: {
path:
'M17.5,2.4c-1.82-1.5-4.21-2.1-6.57-1.64c-3.09,0.6-5.57,3.09-6.15,6.19c-0.4,2.1,0.04,4.21,1.22,5.95 C7.23,14.7,8,16.41,8.36,18.12c0.17,0.81,0.89,1.41,1.72,1.41h4.85c0.83,0,1.55-0.59,1.72-1.42c0.37-1.82,1.13-3.55,2.19-4.99 c1-1.36,1.53-2.96,1.53-4.65C20.37,6.11,19.32,3.9,17.5,2.4z M17.47,12.11c-1.21,1.64-2.07,3.6-2.55,5.72l-4.91-0.05 c-0.4-1.93-1.25-3.84-2.62-5.84c-0.93-1.36-1.27-3.02-0.95-4.67c0.46-2.42,2.39-4.37,4.81-4.83c0.41-0.08,0.82-0.12,1.23-0.12 c1.44,0,2.82,0.49,3.94,1.4c1.43,1.18,2.25,2.91,2.25,4.76C18.67,9.79,18.25,11.04,17.47,12.11z M15.94,20.27H9.61c-0.47,0-0.85,0.38-0.85,0.85s0.38,0.85,0.85,0.85h6.33c0.47,0,0.85-0.38,0.85-0.85 S16.41,20.27,15.94,20.27z M15.94,22.7H9.61c-0.47,0-0.85,0.38-0.85,0.85s0.38,0.85,0.85,0.85h6.33c0.47,0,0.85-0.38,0.85-0.85 S16.41,22.7,15.94,22.7z M12.5,3.28c-2.89,0-5.23,2.35-5.23,5.23c0,0.47,0.38,0.85,0.85,0.85s0.85-0.38,0.85-0.85 c0-1.95,1.59-3.53,3.54-3.53c0.47,0,0.85-0.38,0.85-0.85S12.97,3.28,12.5,3.28z',
@ -304,6 +312,12 @@ export const icons = {
viewBox: '0 0 20 21',
ratio: 1,
},
movie: {
path:
'M18,4L20,8H17L15,4H13L15,8H12L10,4H8L10,8H7L5,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V4H18Z',
viewBox: '0 0 24 24',
ratio: 1,
},
mute: {
path:
'M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM12,6.5c2.49,0 4,2.02 4,4.5v0.1l2,2L18,11c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68c-0.24,0.06 -0.47,0.15 -0.69,0.23l1.64,1.64c0.18,-0.02 0.36,-0.05 0.55,-0.05zM5.41,3.35L4,4.76l2.81,2.81C6.29,8.57 6,9.74 6,11v5l-2,2v1h14.24l1.74,1.74 1.41,-1.41L5.41,3.35zM16,17L8,17v-6c0,-0.68 0.12,-1.32 0.34,-1.9L16,16.76L16,17z',

View File

@ -9,6 +9,7 @@ import { MessageCollection, MessageModel } from '../models/message';
import { MessageAttributes, MessageDirection } from '../models/messageType';
import { StorageItem } from '../node/storage_item';
import { HexKeyPair } from '../receiver/keypairs';
import { Quote } from '../receiver/types';
import { getSodiumRenderer } from '../session/crypto';
import { PubKey } from '../session/types';
import { fromArrayBufferToBase64, fromBase64ToArrayBuffer } from '../session/utils/String';
@ -294,24 +295,6 @@ async function getMessageById(
return new MessageModel(message);
}
async function getMessageBySenderAndSentAt({
source,
sentAt,
}: {
source: string;
sentAt: number;
}): Promise<MessageModel | null> {
const messages = await channels.getMessageBySenderAndSentAt({
source,
sentAt,
});
if (!messages || !messages.length) {
return null;
}
return new MessageModel(messages[0]);
}
async function getMessageByServerId(
conversationId: string,
serverId: number,
@ -336,27 +319,23 @@ async function filterAlreadyFetchedOpengroupMessage(
}
/**
*
* @param source senders id
* @param timestamp the timestamp of the message - not to be confused with the serverTimestamp. This is equivalent to sent_at
* Fetch all messages that match the sender pubkey and sent_at timestamp
* @param propsList An array of objects containing a source (the sender id) and timestamp of the message - not to be confused with the serverTimestamp. This is equivalent to sent_at
* @returns the fetched messageModels
*/
async function getMessageBySenderAndTimestamp({
source,
timestamp,
}: {
source: string;
timestamp: number;
}): Promise<MessageModel | null> {
const messages = await channels.getMessageBySenderAndTimestamp({
source,
timestamp,
});
async function getMessagesBySenderAndSentAt(
propsList: Array<{
source: string;
timestamp: number;
}>
): Promise<MessageCollection | null> {
const messages = await channels.getMessagesBySenderAndSentAt(propsList);
if (!messages || !messages.length) {
return null;
}
return new MessageModel(messages[0]);
return new MessageCollection(messages);
}
async function getUnreadByConversation(
@ -399,17 +378,27 @@ async function getMessageCountByType(
async function getMessagesByConversation(
conversationId: string,
{ skipTimerInit = false, messageId = null }: { skipTimerInit?: false; messageId: string | null }
): Promise<MessageCollection> {
const messages = await channels.getMessagesByConversation(conversationId, {
{
skipTimerInit = false,
returnQuotes = false,
messageId = null,
}: { skipTimerInit?: false; returnQuotes?: boolean; messageId: string | null }
): Promise<{ messages: MessageCollection; quotes: Array<Quote> }> {
const { messages, quotes } = await channels.getMessagesByConversation(conversationId, {
messageId,
returnQuotes,
});
if (skipTimerInit) {
for (const message of messages) {
message.skipTimerInit = skipTimerInit;
}
}
return new MessageCollection(messages);
return {
messages: new MessageCollection(messages),
quotes,
};
}
/**
@ -801,10 +790,9 @@ export const Data = {
removeMessagesByIds,
getMessageIdsFromServerIds,
getMessageById,
getMessageBySenderAndSentAt,
getMessagesBySenderAndSentAt,
getMessageByServerId,
filterAlreadyFetchedOpengroupMessage,
getMessageBySenderAndTimestamp,
getUnreadByConversation,
getUnreadCountByConversation,
markAllAsReadByConversationNoExpiration,

View File

@ -48,9 +48,8 @@ const channelsToMake = new Set([
'getMessageCountByType',
'removeAllMessagesInConversation',
'getMessageCount',
'getMessageBySenderAndSentAt',
'filterAlreadyFetchedOpengroupMessage',
'getMessageBySenderAndTimestamp',
'getMessagesBySenderAndSentAt',
'getMessageIdsFromServerIds',
'getMessageById',
'getMessagesBySentAt',

View File

@ -10,6 +10,7 @@ import { StateType } from '../state/reducer';
import { getMessageReactsProps } from '../state/selectors/conversations';
import { isPrivateAndFriend } from '../state/selectors/selectedConversation';
import { CONVERSATION } from '../session/constants';
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
export function useAvatarPath(convoId: string | undefined) {
const convoProps = useConversationPropsById(convoId);
@ -42,7 +43,7 @@ export function useConversationUsernameOrShorten(convoId?: string) {
}
/**
* Returns the name if that conversation.
* Returns the name of that conversation.
* This is the group name, or the realName of a user for a private conversation with a recent nickname set
*/
export function useConversationRealName(convoId?: string) {
@ -262,3 +263,12 @@ export function useMentionedUs(conversationId?: string): boolean {
export function useIsTyping(conversationId?: string): boolean {
return useConversationPropsById(conversationId)?.isTyping || false;
}
export function useQuoteAuthorName(authorId?: string) {
const convoProps = useConversationPropsById(authorId);
return authorId && isUsAnySogsFromCache(authorId)
? window.i18n('you')
: convoProps?.nickname || convoProps?.isPrivate
? convoProps?.displayNameInProfile
: undefined;
}

View File

@ -112,8 +112,12 @@ const acceptOpenGroupInvitationV2 = (completeUrl: string, roomName?: string) =>
window.inboxStore?.dispatch(
updateConfirmModal({
title: window.i18n('joinOpenGroupAfterInvitationConfirmationTitle', [roomName || 'Unknown']),
message: window.i18n('joinOpenGroupAfterInvitationConfirmationDesc', [roomName || 'Unknown']),
title: window.i18n('joinOpenGroupAfterInvitationConfirmationTitle', [
roomName || window.i18n('unknown'),
]),
message: window.i18n('joinOpenGroupAfterInvitationConfirmationDesc', [
roomName || window.i18n('unknown'),
]),
onClickOk: async () => {
await joinOpenGroupV2WithUIEvents(completeUrl, true, false);
},

View File

@ -21,7 +21,7 @@ import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleM
import { PubKey } from '../session/types';
import { ToastUtils, UserUtils } from '../session/utils';
import { BlockedNumberController } from '../util';
import { MessageModel, sliceQuoteText } from './message';
import { MessageModel } from './message';
import { MessageAttributesOptionals, MessageDirection } from './messageType';
import { Data } from '../../ts/data/data';
@ -489,11 +489,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
msgSource = await findCachedOurBlindedPubkeyOrLookItUp(room.serverPublicKey, sodium);
}
}
return {
author: msgSource,
id: `${quotedMessage.get('sent_at')}` || '',
// no need to quote the full message length.
text: sliceQuoteText(body),
// NOTE we send the entire body to be consistent with the other platforms
text: body,
attachments: quotedAttachments,
timestamp: quotedMessage.get('sent_at') || 0,
convoId: this.id,
@ -1517,7 +1518,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
);
}).length === 1;
const isFirstMessageOfConvo =
(await Data.getMessagesByConversation(this.id, { messageId: null })).length === 1;
(await Data.getMessagesByConversation(this.id, { messageId: null })).messages.length === 1;
if (hadNoRequestsPrior && isFirstMessageOfConvo) {
friendRequestText = window.i18n('youHaveANewFriendRequest');
} else {

View File

@ -30,24 +30,20 @@ import {
debounce,
groupBy,
isEmpty,
size as lodashSize,
map,
partition,
pick,
reject,
size as lodashSize,
sortBy,
uniq,
} from 'lodash';
import { Data } from '../../ts/data/data';
import { OpenGroupData } from '../data/opengroups';
import { SettingsKey } from '../data/settings-key';
import {
findCachedBlindedIdFromUnblinded,
isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
import { SnodeNamespaces } from '../session/apis/snode_api/namespaces';
import { QUOTED_TEXT_MAX_LENGTH } from '../session/constants';
import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import {
VisibleMessage,
@ -77,7 +73,7 @@ import {
PropsForGroupUpdateLeft,
PropsForGroupUpdateName,
PropsForMessageWithoutConvoProps,
ReduxQuoteType,
PropsForQuote,
} from '../state/ducks/conversations';
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
@ -89,12 +85,12 @@ import {
loadQuoteData,
} from '../types/MessageAttachment';
import { ReactionList } from '../types/Reaction';
import { roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { ExpirationTimerOptions } from '../util/expiringMessages';
import { LinkPreviews } from '../util/linkPreviews';
import { Notifications } from '../util/notifications';
import { Storage } from '../util/storage';
import { ConversationModel } from './conversation';
import { roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { READ_MESSAGE_STATE } from './conversationAttributes';
// tslint:disable: cyclomatic-complexity
@ -145,6 +141,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const propsForGroupUpdateMessage = this.getPropsForGroupUpdateMessage();
const propsForTimerNotification = this.getPropsForTimerNotification();
const propsForMessageRequestResponse = this.getPropsForMessageRequestResponse();
const propsForQuote = this.getPropsForQuote();
const callNotificationType = this.get('callNotificationType');
const messageProps: MessageModelPropsWithoutConvoProps = {
propsForMessage: this.getPropsForMessage(),
@ -164,6 +161,9 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (propsForTimerNotification) {
messageProps.propsForTimerNotification = propsForTimerNotification;
}
if (propsForQuote) {
messageProps.propsForQuote = propsForQuote;
}
if (callNotificationType) {
messageProps.propsForCallNotification = {
@ -284,7 +284,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const disabled = !expireTimer;
const basicProps: PropsForExpirationTimer = {
...this.findAndFormatContact(source),
...findAndFormatContact(source),
timespan,
disabled,
type: fromSync ? 'fromSync' : UserUtils.isUsFromCache(source) ? 'fromMe' : 'fromOther',
@ -337,7 +337,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return null;
}
const contact = this.findAndFormatContact(dataExtractionNotification.source);
const contact = findAndFormatContact(dataExtractionNotification.source);
return {
...dataExtractionNotification,
@ -359,7 +359,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return null;
}
const contact = this.findAndFormatContact(messageRequestResponse.source);
const contact = findAndFormatContact(messageRequestResponse.source);
return {
...messageRequestResponse,
@ -461,7 +461,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
// tslint:disable-next-line: cyclomatic-complexity
public getPropsForMessage(options: any = {}): PropsForMessageWithoutConvoProps {
public getPropsForMessage(): PropsForMessageWithoutConvoProps {
const sender = this.getSource();
const expirationLength = this.get('expireTimer') * 1000;
const expireTimerStart = this.get('expirationStartTimestamp');
@ -522,7 +522,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (reacts && Object.keys(reacts).length) {
props.reacts = reacts;
}
const quote = this.getPropsForQuote(options);
const quote = this.getPropsForQuote();
if (quote) {
props.quote = quote;
}
@ -539,26 +539,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return props;
}
public processQuoteAttachment(attachment: any) {
const { thumbnail } = attachment;
const path = thumbnail && thumbnail.path && getAbsoluteAttachmentPath(thumbnail.path);
const objectUrl = thumbnail && thumbnail.objectUrl;
const thumbnailWithObjectUrl =
!path && !objectUrl
? null
: // tslint:disable: prefer-object-spread
Object.assign({}, attachment.thumbnail || {}, {
objectUrl: path || objectUrl,
});
return Object.assign({}, attachment, {
isVoiceMessage: isVoiceMessage(attachment),
thumbnail: thumbnailWithObjectUrl,
});
// tslint:enable: prefer-object-spread
}
public getPropsForPreview(): Array<any> | null {
const previews = this.get('preview') || null;
@ -588,64 +568,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return this.get('reacts') || null;
}
public getPropsForQuote(_options: any = {}) {
const quote = this.get('quote');
if (!quote) {
return null;
}
const { author, id, referencedMessageNotFound } = quote;
const contact: ConversationModel = author && getConversationController().get(author);
const authorName = contact ? contact.getContactProfileNameOrShortenedPubKey() : null;
let isFromMe = contact ? contact.id === UserUtils.getOurPubKeyStrFromCache() : false;
if (this.getConversation()?.isPublic() && PubKey.hasBlindedPrefix(author)) {
const room = OpenGroupData.getV2OpenGroupRoom(this.get('conversationId'));
if (room && roomHasBlindEnabled(room)) {
const usFromCache = findCachedBlindedIdFromUnblinded(
UserUtils.getOurPubKeyStrFromCache(),
room.serverPublicKey
);
if (usFromCache && usFromCache === author) {
isFromMe = true;
}
}
}
const firstAttachment = quote.attachments && quote.attachments[0];
const quoteProps: ReduxQuoteType = {
sender: author,
messageId: id,
authorName: authorName || 'Unknown',
};
if (referencedMessageNotFound) {
quoteProps.referencedMessageNotFound = true;
}
if (!referencedMessageNotFound) {
if (quote.text) {
// do not show text of not found messages.
// if the message was deleted better not show it's text content in the message
quoteProps.text = sliceQuoteText(quote.text);
}
const quoteAttachment = firstAttachment
? this.processQuoteAttachment(firstAttachment)
: undefined;
if (quoteAttachment) {
// only set attachment if referencedMessageNotFound is false and we have one
quoteProps.attachment = quoteAttachment;
}
}
if (isFromMe) {
quoteProps.isFromMe = true;
}
return quoteProps;
public getPropsForQuote(): PropsForQuote | null {
return this.get('quote') || null;
}
public getPropsForAttachment(attachment: AttachmentTypeWithPath): PropsForAttachment | null {
@ -721,7 +645,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
(contacts || []).map(async id => {
const errorsForContact = errorsGroupedById[id];
const contact = this.findAndFormatContact(id);
const contact = findAndFormatContact(id);
return {
...contact,
status: this.getMessagePropStatus(),
@ -1188,31 +1112,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
}
public findAndFormatContact(pubkey: string): FindAndFormatContactType {
const contactModel = getConversationController().get(pubkey);
let profileName: string | null = null;
let isMe = false;
if (
pubkey === UserUtils.getOurPubKeyStrFromCache() ||
(pubkey && pubkey.startsWith('15') && isUsAnySogsFromCache(pubkey))
) {
profileName = window.i18n('you');
isMe = true;
} else {
profileName = contactModel?.getNicknameOrRealUsername() || null;
}
return {
pubkey: pubkey,
avatarPath: contactModel ? contactModel.getAvatarPath() : null,
name: contactModel?.getRealSessionUsername() || null,
profileName,
title: contactModel?.getNicknameOrRealUsernameOrPlaceholder() || null,
isMe,
};
}
private dispatchMessageUpdate() {
updatesToDispatch.set(this.id, this.getMessageModelProps());
throttledAllMessagesDispatch();
@ -1356,14 +1255,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
}
// this is to avoid saving 2k chars for just the quote object inside a message
export function sliceQuoteText(quotedText: string | undefined | null) {
if (!quotedText || isEmpty(quotedText)) {
return '';
}
return quotedText.slice(0, QUOTED_TEXT_MAX_LENGTH);
}
const throttledAllMessagesDispatch = debounce(
() => {
if (updatesToDispatch.size === 0) {
@ -1380,3 +1271,47 @@ const updatesToDispatch: Map<string, MessageModelPropsWithoutConvoProps> = new M
export class MessageCollection extends Backbone.Collection<MessageModel> {}
MessageCollection.prototype.model = MessageModel;
export function findAndFormatContact(pubkey: string): FindAndFormatContactType {
const contactModel = getConversationController().get(pubkey);
let profileName: string | null = null;
let isMe = false;
if (
pubkey === UserUtils.getOurPubKeyStrFromCache() ||
(pubkey && pubkey.startsWith('15') && isUsAnySogsFromCache(pubkey))
) {
profileName = window.i18n('you');
isMe = true;
} else {
profileName = contactModel?.getNicknameOrRealUsername() || null;
}
return {
pubkey: pubkey,
avatarPath: contactModel ? contactModel.getAvatarPath() : null,
name: contactModel?.getRealSessionUsername() || null,
profileName,
isMe,
};
}
export function processQuoteAttachment(attachment: any) {
const { thumbnail } = attachment;
const path = thumbnail && thumbnail.path && getAbsoluteAttachmentPath(thumbnail.path);
const objectUrl = thumbnail && thumbnail.objectUrl;
const thumbnailWithObjectUrl =
!path && !objectUrl
? null
: // tslint:disable: prefer-object-spread
Object.assign({}, attachment.thumbnail || {}, {
objectUrl: path || objectUrl,
});
return Object.assign({}, attachment, {
isVoiceMessage: isVoiceMessage(attachment),
thumbnail: thumbnailWithObjectUrl,
});
// tslint:enable: prefer-object-spread
}

View File

@ -18,6 +18,7 @@ import {
last,
map,
omit,
uniq,
} from 'lodash';
import { ConversationAttributes } from '../models/conversationAttributes';
import { PubKey } from '../session/types/PubKey'; // checked - only node
@ -71,6 +72,7 @@ import {
} from './sqlInstance';
import { configDumpData } from './sql_calls/config_dump';
import { base64_variants, from_base64, to_hex } from 'libsodium-wrappers-sumo';
import { Quote } from '../receiver/types';
// tslint:disable: no-console function-name non-literal-fs-path
@ -1018,21 +1020,6 @@ function getMessageById(id: string) {
return jsonToObject(row.json);
}
function getMessageBySenderAndSentAt({ source, sentAt }: { source: string; sentAt: number }) {
const rows = assertGlobalInstance()
.prepare(
`SELECT json FROM ${MESSAGES_TABLE} WHERE
source = $source AND
sent_at = $sent_at;`
)
.all({
source,
sent_at: sentAt,
});
return map(rows, row => jsonToObject(row.json));
}
// serverIds are not unique so we need the conversationId
function getMessageByServerId(conversationId: string, serverId: number) {
const row = assertGlobalInstance()
@ -1070,25 +1057,32 @@ function getMessagesCountBySender({ source }: { source: string }) {
return count['count(*)'] || 0;
}
function getMessageBySenderAndTimestamp({
source,
timestamp,
}: {
source: string;
timestamp: number;
}) {
const rows = assertGlobalInstance()
.prepare(
`SELECT json FROM ${MESSAGES_TABLE} WHERE
function getMessagesBySenderAndSentAt(
propsList: Array<{
source: string;
timestamp: number;
}>
) {
const db = assertGlobalInstance();
const rows = [];
for (const msgProps of propsList) {
const { source, timestamp } = msgProps;
const _rows = db
.prepare(
`SELECT json FROM ${MESSAGES_TABLE} WHERE
source = $source AND
sent_at = $timestamp;`
)
.all({
source,
timestamp,
});
)
.all({
source,
timestamp,
});
rows.push(..._rows);
}
return map(rows, row => jsonToObject(row.json));
return uniq(map(rows, row => jsonToObject(row.json)));
}
function filterAlreadyFetchedOpengroupMessage(
@ -1220,7 +1214,10 @@ function getMessageCountByType(conversationId: string, type = '%') {
const orderByClause = 'ORDER BY COALESCE(serverTimestamp, sent_at, received_at) DESC';
const orderByClauseASC = 'ORDER BY COALESCE(serverTimestamp, sent_at, received_at) ASC';
function getMessagesByConversation(conversationId: string, { messageId = null } = {}) {
function getMessagesByConversation(
conversationId: string,
{ messageId = null, returnQuotes = false } = {}
): { messages: Array<Record<string, any>>; quotes: Array<Quote> } {
const absLimit = 30;
// If messageId is given it means we are opening the conversation to that specific messageId,
// or that we just scrolled to it by a quote click and needs to load around it.
@ -1230,6 +1227,9 @@ function getMessagesByConversation(conversationId: string, { messageId = null }
const numberOfMessagesInConvo = getMessagesCountByConversation(conversationId);
const floorLoadAllMessagesInConvo = 70;
let messages: Array<Record<string, any>> = [];
let quotes = [];
if (messageId || firstUnread) {
const messageFound = getMessageById(messageId || firstUnread);
@ -1269,7 +1269,7 @@ function getMessagesByConversation(conversationId: string, { messageId = null }
console.info(`getMessagesByConversation around took ${Date.now() - start}ms `);
// sorting is made in redux already when rendered, but some things are made outside of redux, so let's make sure the order is right
return map([...messagesBefore, ...messagesAfter], row => jsonToObject(row.json)).sort(
messages = map([...messagesBefore, ...messagesAfter], row => jsonToObject(row.json)).sort(
(a, b) => {
return (
(b.serverTimestamp || b.sent_at || b.received_at) -
@ -1281,28 +1281,34 @@ function getMessagesByConversation(conversationId: string, { messageId = null }
console.info(
`getMessagesByConversation: Could not find messageId ${messageId} in db with conversationId: ${conversationId}. Just fetching the convo as usual.`
);
}
} else {
const limit =
numberOfMessagesInConvo < floorLoadAllMessagesInConvo
? floorLoadAllMessagesInConvo
: absLimit * 2;
const limit =
numberOfMessagesInConvo < floorLoadAllMessagesInConvo
? floorLoadAllMessagesInConvo
: absLimit * 2;
const rows = assertGlobalInstance()
.prepare(
`
const rows = assertGlobalInstance()
.prepare(
`
SELECT json FROM ${MESSAGES_TABLE} WHERE
conversationId = $conversationId
${orderByClause}
LIMIT $limit;
`
)
.all({
conversationId,
limit,
});
)
.all({
conversationId,
limit,
});
return map(rows, row => jsonToObject(row.json));
messages = map(rows, row => jsonToObject(row.json));
}
if (returnQuotes) {
quotes = uniq(messages.filter(message => message.quote).map(message => message.quote));
}
return { messages, quotes };
}
function getLastMessagesByConversation(conversationId: string, limit: number) {
@ -2374,9 +2380,8 @@ export const sqlNode = {
getUnreadCountByConversation,
getMessageCountByType,
getMessageBySenderAndSentAt,
filterAlreadyFetchedOpengroupMessage,
getMessageBySenderAndTimestamp,
getMessagesBySenderAndSentAt,
getMessageIdsFromServerIds,
getMessageById,
getMessagesBySentAt,

View File

@ -616,10 +616,14 @@ async function handleUnsendMessage(envelope: EnvelopePlus, unsendMessage: Signal
return;
}
const messageToDelete = await Data.getMessageBySenderAndTimestamp({
source: messageAuthor,
timestamp: toNumber(timestamp),
});
const messageToDelete = (
await Data.getMessagesBySenderAndSentAt([
{
source: messageAuthor,
timestamp: toNumber(timestamp),
},
])
)?.models?.[0];
const messageHash = messageToDelete?.get('messageHash');
//#endregion
@ -716,7 +720,7 @@ async function handleMessageRequestResponse(
)
);
const allMessageModels = flatten(allMessagesCollections.map(m => m.models));
const allMessageModels = flatten(allMessagesCollections.map(m => m.messages.models));
allMessageModels.forEach(messageModel => {
messageModel.set({ conversationId: unblindedConvoId });

View File

@ -267,10 +267,14 @@ export async function isSwarmMessageDuplicate({
sentAt: number;
}) {
try {
const result = await Data.getMessageBySenderAndSentAt({
source,
sentAt,
});
const result = (
await Data.getMessagesBySenderAndSentAt([
{
source,
timestamp: sentAt,
},
])
)?.models?.length;
return Boolean(result);
} catch (error) {

View File

@ -3,7 +3,7 @@ import { queueAttachmentDownloads } from './attachments';
import _ from 'lodash';
import { Data } from '../../ts/data/data';
import { ConversationModel } from '../models/conversation';
import { MessageModel, sliceQuoteText } from '../models/message';
import { MessageModel } from '../models/message';
import { getConversationController } from '../session/conversations';
import { Quote } from './types';
@ -66,7 +66,8 @@ async function copyFromQuotedMessage(
window?.log?.info(`Found quoted message id: ${id}`);
quoteLocal.referencedMessageNotFound = false;
quoteLocal.text = sliceQuoteText(found.get('body') || '');
// NOTE we send the entire body to be consistent with the other platforms
quoteLocal.text = found.get('body') || '';
// no attachments, just save the quote with the body
if (
@ -368,7 +369,7 @@ export async function handleMessageJob(
);
}
// save the message model to the db and it save the messageId generated to our in-memory copy
// save the message model to the db and then save the messageId generated to our in-memory copy
const id = await messageModel.commit();
messageModel.set({ id });

View File

@ -61,9 +61,6 @@ export const UI = {
},
};
// we keep 150 chars, because quoting someone with 66 hex chars need to be kept in full so we can render it in the quote with its name
export const QUOTED_TEXT_MAX_LENGTH = 150;
export const DEFAULT_RECENT_REACTS = ['😂', '🥰', '😢', '😡', '😮', '😈'];
export const MAX_USERNAME_BYTES = 64;

View File

@ -68,7 +68,7 @@ export async function initiateOpenGroupUpdate(
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
});
await convo.setSessionProfile({
displayName: groupName || convo.get('displayNameInProfile') || 'Unknown',
displayName: groupName || convo.get('displayNameInProfile') || window.i18n('unknown'),
avatarPath: upgraded.path,
avatarImageId,
});

View File

@ -1164,7 +1164,7 @@ export async function handleMissedCall(
const displayname =
incomingCallConversation?.getNickname() ||
incomingCallConversation?.getRealSessionUsername() ||
'Unknown';
window.i18n('unknown');
switch (reason) {
case 'permissions':

View File

@ -1,7 +1,7 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { omit } from 'lodash';
import { omit, toNumber } from 'lodash';
import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox';
import { QuotedAttachmentType } from '../../components/conversation/message/message-content/Quote';
import { QuotedAttachmentType } from '../../components/conversation/message/message-content/quote/Quote';
import { LightBoxOptions } from '../../components/conversation/SessionConversation';
import { Data } from '../../data/data';
import {
@ -33,6 +33,7 @@ export type MessageModelPropsWithoutConvoProps = {
propsForGroupUpdateMessage?: PropsForGroupUpdate;
propsForCallNotification?: PropsForCallNotification;
propsForMessageRequestResponse?: PropsForMessageRequestResponse;
propsForQuote?: PropsForQuote;
};
export type MessageModelPropsWithConvoProps = SortedMessageModelProps & {
@ -65,7 +66,6 @@ export type FindAndFormatContactType = {
avatarPath: string | null;
name: string | null;
profileName: string | null;
title: string | null;
isMe: boolean;
};
@ -76,7 +76,6 @@ export type PropsForExpirationTimer = {
avatarPath: string | null;
name: string | null;
profileName: string | null;
title: string | null;
type: 'fromMe' | 'fromSync' | 'fromOther';
messageId: string;
isUnread: boolean;
@ -161,16 +160,15 @@ export type PropsForAttachment = {
} | null;
};
export type ReduxQuoteType = {
text?: string;
export type PropsForQuote = {
attachment?: QuotedAttachmentType;
author: string;
convoId?: string;
id?: string; // this is the quoted message timestamp
isFromMe?: boolean;
sender: string;
authorProfileName?: string;
authorName?: string;
messageId?: string;
referencedMessageNotFound?: boolean;
} | null;
text?: string;
};
export type PropsForMessageWithoutConvoProps = {
id: string; // messageId
@ -188,7 +186,7 @@ export type PropsForMessageWithoutConvoProps = {
reacts?: ReactionList;
reactsIndex?: number;
previews?: Array<any>;
quote?: ReduxQuoteType;
quote?: PropsForQuote;
messageHash?: string;
isDeleted?: boolean;
isUnread?: boolean;
@ -270,10 +268,18 @@ export type ConversationLookupType = {
[key: string]: ReduxConversationType;
};
export type QuoteLookupType = {
// key is message [timestamp]-[author-pubkey]
[key: string]: MessageModelPropsWithoutConvoProps;
};
export type ConversationsStateType = {
conversationLookup: ConversationLookupType;
selectedConversation?: string;
// NOTE the messages that are in view
messages: Array<MessageModelPropsWithoutConvoProps>;
// NOTE the messages quoted by other messages which are in view
quotes: QuoteLookupType;
firstUnreadMessageId: string | undefined;
messageDetailProps?: MessagePropsDetails;
showRightPanel: boolean;
@ -319,32 +325,70 @@ export type MentionsMembersType = Array<{
authorProfileName: string;
}>;
/**
* Fetches the messages for a conversation to put into redux.
* @param conversationKey - the id of the conversation
* @param messageId - the id of the message in view so we can fetch the messages around it
* @returns the fetched models for messages and quoted messages
*/
async function getMessages({
conversationKey,
messageId,
}: {
conversationKey: string;
messageId: string | null;
}): Promise<Array<MessageModelPropsWithoutConvoProps>> {
}): Promise<{
messagesProps: Array<MessageModelPropsWithoutConvoProps>;
quotesProps: QuoteLookupType;
}> {
const beforeTimestamp = Date.now();
const conversation = getConversationController().get(conversationKey);
if (!conversation) {
// no valid conversation, early return
window?.log?.error('Failed to get convo on reducer.');
return [];
return { messagesProps: [], quotesProps: {} };
}
const messageSet = await Data.getMessagesByConversation(conversationKey, {
const {
messages: messagesCollection,
quotes: quotesCollection,
} = await Data.getMessagesByConversation(conversationKey, {
messageId,
returnQuotes: true,
});
const messageProps: Array<MessageModelPropsWithoutConvoProps> = messageSet.models.map(m =>
m.getMessageModelProps()
const messagesProps: Array<MessageModelPropsWithoutConvoProps> = messagesCollection.models.map(
m => m.getMessageModelProps()
);
const time = Date.now() - beforeTimestamp;
window?.log?.info(`Loading ${messageProps.length} messages took ${time}ms to load.`);
return messageProps;
window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`);
const quotesProps: QuoteLookupType = {};
if (quotesCollection?.length) {
const quotePropsList = quotesCollection.map(quote => ({
timestamp: toNumber(quote.id),
source: String(quote.author),
}));
const quotedMessagesCollection = await Data.getMessagesBySenderAndSentAt(quotePropsList);
if (quotedMessagesCollection?.length) {
for (let i = 0; i < quotedMessagesCollection.length; i++) {
const quotedMessage = quotedMessagesCollection.models.at(i)?.getMessageModelProps();
if (quotedMessage) {
const timestamp = quotedMessage.propsForMessage.timestamp;
const sender = quotedMessage.propsForMessage.sender;
if (timestamp && sender) {
quotesProps[`${timestamp}-${sender}`] = quotedMessage;
}
}
}
}
}
return { messagesProps, quotesProps };
}
export type SortedMessageModelProps = MessageModelPropsWithoutConvoProps & {
@ -355,6 +399,7 @@ export type SortedMessageModelProps = MessageModelPropsWithoutConvoProps & {
type FetchedTopMessageResults = {
conversationKey: string;
messagesProps: Array<MessageModelPropsWithoutConvoProps>;
quotesProps: QuoteLookupType;
oldTopMessageId: string | null;
newMostRecentMessageIdInConversation: string | null;
} | null;
@ -376,7 +421,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
// window.log.debug('fetchTopMessagesForConversation: we are already at the top');
return null;
}
const messagesProps = await getMessages({
const { messagesProps, quotesProps } = await getMessages({
conversationKey,
messageId: oldTopMessageId,
});
@ -384,6 +429,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
return {
conversationKey,
messagesProps,
quotesProps,
oldTopMessageId,
newMostRecentMessageIdInConversation: mostRecentMessage?.id || null,
};
@ -393,6 +439,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
type FetchedBottomMessageResults = {
conversationKey: string;
messagesProps: Array<MessageModelPropsWithoutConvoProps>;
quotesProps: QuoteLookupType;
oldBottomMessageId: string | null;
newMostRecentMessageIdInConversation: string | null;
} | null;
@ -413,7 +460,7 @@ export const fetchBottomMessagesForConversation = createAsyncThunk(
// window.log.debug('fetchBottomMessagesForConversation: we are already at the bottom');
return null;
}
const messagesProps = await getMessages({
const { messagesProps, quotesProps } = await getMessages({
conversationKey,
messageId: oldBottomMessageId,
});
@ -421,6 +468,7 @@ export const fetchBottomMessagesForConversation = createAsyncThunk(
return {
conversationKey,
messagesProps,
quotesProps,
oldBottomMessageId,
newMostRecentMessageIdInConversation: mostRecentMessage.id,
};
@ -433,6 +481,7 @@ export function getEmptyConversationState(): ConversationsStateType {
return {
conversationLookup: {},
messages: [],
quotes: {},
messageDetailProps: undefined,
showRightPanel: false,
selectedMessageIds: [],
@ -500,6 +549,7 @@ function handleMessageExpiredOrDeleted(
// search if we find this message id.
// we might have not loaded yet, so this case might not happen
const messageInStoreIndex = state?.messages.findIndex(m => m.propsForMessage.id === messageId);
const editedQuotes = { ...state.quotes };
if (messageInStoreIndex >= 0) {
// we cannot edit the array directly, so slice the first part, and slice the second part,
// keeping the index removed out
@ -508,9 +558,23 @@ function handleMessageExpiredOrDeleted(
...state.messages.slice(messageInStoreIndex + 1),
];
// Check if the message is quoted somewhere, and if so, remove it from the quotes
const msgProps = state.messages[messageInStoreIndex].propsForMessage;
const { timestamp, sender } = msgProps;
if (timestamp && sender) {
const message2Delete = lookupQuote(editedQuotes, editedMessages, timestamp, sender);
window.log.debug(
`Deleting quote {${timestamp}-${sender}} ${JSON.stringify(message2Delete)}`
);
// tslint:disable-next-line: no-dynamic-delete
delete editedQuotes[`${timestamp}-${sender}`];
}
return {
...state,
messages: editedMessages,
quotes: editedQuotes,
firstUnreadMessageId:
state.firstUnreadMessageId === messageId ? undefined : state.firstUnreadMessageId,
};
@ -733,6 +797,7 @@ const conversationsSlice = createSlice({
firstUnreadIdOnOpen: string | undefined;
mostRecentMessageIdOnOpen: string | null;
initialMessages: Array<MessageModelPropsWithoutConvoProps>;
initialQuotes: QuoteLookupType;
}>
) {
// this is quite hacky, but we don't want to show the showScrollButton if we have only a small amount of messages,
@ -756,6 +821,7 @@ const conversationsSlice = createSlice({
selectedConversation: action.payload.conversationKey,
firstUnreadMessageId: action.payload.firstUnreadIdOnOpen,
messages: action.payload.initialMessages,
quotes: action.payload.initialQuotes,
areMoreMessagesBeingFetched: false,
showRightPanel: false,
@ -783,6 +849,7 @@ const conversationsSlice = createSlice({
mostRecentMessageIdOnOpen: string | null;
initialMessages: Array<MessageModelPropsWithoutConvoProps>;
initialQuotes: QuoteLookupType;
}>
) {
return {
@ -791,6 +858,7 @@ const conversationsSlice = createSlice({
mostRecentMessageIdOnOpen: action.payload.mostRecentMessageIdOnOpen,
areMoreMessagesBeingFetched: false,
messages: action.payload.initialMessages,
quotes: action.payload.initialQuotes,
showScrollButton: Boolean(
action.payload.messageIdToNavigateTo !== action.payload.mostRecentMessageIdOnOpen
),
@ -1043,7 +1111,7 @@ export async function openConversationWithMessages(args: {
const firstUnreadIdOnOpen = await Data.getFirstUnreadMessageIdInConversation(conversationKey);
const mostRecentMessageIdOnOpen = await Data.getLastMessageIdInConversation(conversationKey);
const initialMessages = await getMessages({
const { messagesProps: initialMessages, quotesProps: initialQuotes } = await getMessages({
conversationKey,
messageId: messageId || null,
});
@ -1054,6 +1122,7 @@ export async function openConversationWithMessages(args: {
firstUnreadIdOnOpen,
mostRecentMessageIdOnOpen,
initialMessages,
initialQuotes,
})
);
}
@ -1066,7 +1135,10 @@ export async function openConversationToSpecificMessage(args: {
const { conversationKey, messageIdToNavigateTo, shouldHighlightMessage } = args;
await unmarkAsForcedUnread(conversationKey);
const messagesAroundThisMessage = await getMessages({
const {
messagesProps: messagesAroundThisMessage,
quotesProps: quotesAroundThisMessage,
} = await getMessages({
conversationKey,
messageId: messageIdToNavigateTo,
});
@ -1081,6 +1153,41 @@ export async function openConversationToSpecificMessage(args: {
mostRecentMessageIdOnOpen,
shouldHighlightMessage,
initialMessages: messagesAroundThisMessage,
initialQuotes: quotesAroundThisMessage,
})
);
}
/**
* Look for quote matching the timestamp and author in the quote lookup map
* @param quotes - the lookup map of the selected conversations quotes
* @param author - the pubkey of the quoted author
* @param timestamp - usually the id prop on the quote object of a message
* @returns - the message model if found, undefined otherwise
*/
export function lookupQuote(
quotes: QuoteLookupType,
messages: Array<MessageModelPropsWithoutConvoProps>,
timestamp: number,
author: string
): MessageModelPropsWithoutConvoProps | undefined {
let sourceMessage = quotes[`${timestamp}-${author}`];
// NOTE If a quote is processed but we haven't triggered a render, the quote might not be in the lookup map yet so we check the messages in memory.
if (!sourceMessage) {
const quotedMessages = messages.filter(message => {
const msgProps = message.propsForMessage;
return msgProps.timestamp === timestamp && msgProps.sender === author;
});
if (quotedMessages?.length) {
for (const quotedMessage of quotedMessages) {
if (quotedMessage) {
sourceMessage = quotedMessage;
}
}
}
}
return sourceMessage;
}

View File

@ -3,10 +3,13 @@ import { createSelector } from '@reduxjs/toolkit';
import {
ConversationLookupType,
ConversationsStateType,
lookupQuote,
MentionsMembersType,
MessageModelPropsWithConvoProps,
MessageModelPropsWithoutConvoProps,
MessagePropsDetails,
PropsForQuote,
QuoteLookupType,
ReduxConversationType,
SortedMessageModelProps,
} from '../ducks/conversations';
@ -29,8 +32,11 @@ import { BlockedNumberController } from '../../util';
import { Storage } from '../../util/storage';
import { getIntl } from './user';
import { filter, isEmpty, isNumber, pick, sortBy } from 'lodash';
import { filter, isEmpty, isNumber, pick, sortBy, toNumber } from 'lodash';
import { MessageReactsSelectorProps } from '../../components/conversation/message/message-content/MessageReactions';
import { processQuoteAttachment } from '../../models/message';
import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { PubKey } from '../../session/types';
import { getSelectedConversationKey } from './selectedConversation';
import { getModeratorsOutsideRedux } from './sogsRoomInfo';
@ -44,6 +50,10 @@ export const getConversationsCount = createSelector(getConversationLookup, (stat
return Object.keys(state).length;
});
const getConversationQuotes = (state: StateType): QuoteLookupType | undefined => {
return state.conversations.quotes;
};
export const getOurPrimaryConversation = createSelector(
getConversations,
(state: ConversationsStateType): ReduxConversationType =>
@ -743,6 +753,90 @@ export const getMessageReactsProps = createSelector(getMessagePropsByMessageId,
return msgProps;
});
// tslint:disable: cyclomatic-complexity
export const getMessageQuoteProps = createSelector(
getConversationLookup,
getMessagesOfSelectedConversation,
getConversationQuotes,
getMessagePropsByMessageId,
(
conversationLookup,
messagesProps,
quotesProps,
msgGlobalProps
): { quote: PropsForQuote } | undefined => {
if (!msgGlobalProps || isEmpty(msgGlobalProps)) {
return undefined;
}
const msgProps = msgGlobalProps.propsForMessage;
if (!msgProps.quote || isEmpty(msgProps.quote)) {
return undefined;
}
const { id } = msgProps.quote;
let { author } = msgProps.quote;
if (!id || !author) {
return undefined;
}
const isFromMe = isUsAnySogsFromCache(author) || false;
// NOTE the quote lookup map always stores our messages using the unblinded pubkey
if (isFromMe && PubKey.hasBlindedPrefix(author)) {
author = UserUtils.getOurPubKeyStrFromCache();
}
// NOTE: if the message is not found, we still want to render the quote
const quoteNotFound = {
quote: {
id,
author,
isFromMe,
referencedMessageNotFound: true,
},
};
if (!quotesProps || isEmpty(quotesProps)) {
return quoteNotFound;
}
const sourceMessage = lookupQuote(quotesProps, messagesProps, toNumber(id), author);
if (!sourceMessage) {
return quoteNotFound;
}
const sourceMsgProps = sourceMessage.propsForMessage;
if (!sourceMsgProps || sourceMsgProps.isDeleted) {
return quoteNotFound;
}
const convo = conversationLookup[sourceMsgProps.convoId];
if (!convo) {
return quoteNotFound;
}
const attachment = sourceMsgProps.attachments && sourceMsgProps.attachments[0];
const quote: PropsForQuote = {
text: sourceMsgProps.text,
attachment: attachment ? processQuoteAttachment(attachment) : undefined,
isFromMe,
author: sourceMsgProps.sender,
id: sourceMsgProps.id,
referencedMessageNotFound: false,
convoId: convo.id,
};
return {
quote,
};
}
);
// tslint:enable: cyclomatic-complexity
export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (props):
| MessageTextSelectorProps
| undefined => {

View File

@ -1,11 +1,10 @@
import { useSelector } from 'react-redux';
import { UserUtils } from '../../session/utils';
import {
MessageModelPropsWithConvoProps,
ReduxConversationType,
PropsForAttachment,
ReduxQuoteType,
LastMessageStatusType,
MessageModelPropsWithConvoProps,
PropsForAttachment,
ReduxConversationType,
} from '../ducks/conversations';
import { StateType } from '../reducer';
import { getMessagePropsByMessageId } from './conversations';
@ -101,10 +100,6 @@ export const useMessageIsDeletable = (messageId: string | undefined): boolean =>
return useMessageIdProps(messageId)?.propsForMessage.isDeletable || false;
};
export const useMessageQuote = (messageId: string | undefined): ReduxQuoteType | undefined => {
return useMessageIdProps(messageId)?.propsForMessage.quote;
};
export const useMessageStatus = (
messageId: string | undefined
): LastMessageStatusType | undefined => {

View File

@ -45,6 +45,7 @@ export type LocalizerKeys =
| 'incomingError'
| 'media'
| 'mediaEmptyState'
| 'document'
| 'documents'
| 'documentsEmptyState'
| 'today'
@ -84,6 +85,7 @@ export type LocalizerKeys =
| 'you'
| 'audioPermissionNeededTitle'
| 'audioPermissionNeeded'
| 'image'
| 'audio'
| 'video'
| 'photo'

View File

@ -1101,10 +1101,10 @@
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.9.tgz#89c3ad2156d5143e64bce86cfeb0045a983aeccc"
integrity sha512-LisgKLlYQk19baQwjkBZZXdJL0KbeTpdEnrAfz5hQACbklCY0gVFnsKUyjfNWF1UQsCSjw93Sj5jSbiO8RPfdw==
"@types/linkify-it@2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.0.3.tgz#5352a2d7a35d7c77b527483cd6e68da9148bd780"
integrity sha512-WRj1rJPkj3fyDME63xUkWvcWzq0XjpiqjJGNCH4Y6Fbiv2fVMDCqeIA6ch/UUG3VYrfGq1VNFLsz6Sat6FjsPw==
"@types/linkify-it@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9"
integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==
"@types/lodash@^4.14.194":
version "4.14.194"
@ -5179,10 +5179,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-it@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8"
integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==
linkify-it@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==
dependencies:
uc.micro "^1.0.1"