Merge remote-tracking branch 'upstream/clearnet' into unstable
This commit is contained in:
commit
594eee698b
|
@ -3,6 +3,9 @@ components/**
|
|||
dist/**
|
||||
mnemonic_languages/**
|
||||
|
||||
# editor
|
||||
.vscode/**
|
||||
|
||||
# TypeScript generated files
|
||||
ts/**/*.js
|
||||
**/ts/**/*.js
|
||||
|
|
|
@ -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/**
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
// /////////////////// //
|
||||
@import 'modules';
|
||||
@import 'session';
|
||||
@import 'quote';
|
||||
@import 'rtl';
|
||||
|
||||
// Separate screens
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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'} />
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -48,9 +48,8 @@ const channelsToMake = new Set([
|
|||
'getMessageCountByType',
|
||||
'removeAllMessagesInConversation',
|
||||
'getMessageCount',
|
||||
'getMessageBySenderAndSentAt',
|
||||
'filterAlreadyFetchedOpengroupMessage',
|
||||
'getMessageBySenderAndTimestamp',
|
||||
'getMessagesBySenderAndSentAt',
|
||||
'getMessageIdsFromServerIds',
|
||||
'getMessageById',
|
||||
'getMessagesBySentAt',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
105
ts/node/sql.ts
105
ts/node/sql.ts
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -1164,7 +1164,7 @@ export async function handleMissedCall(
|
|||
const displayname =
|
||||
incomingCallConversation?.getNickname() ||
|
||||
incomingCallConversation?.getRealSessionUsername() ||
|
||||
'Unknown';
|
||||
window.i18n('unknown');
|
||||
|
||||
switch (reason) {
|
||||
case 'permissions':
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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'
|
||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -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"
|
||||
|
||||
|
|
Loading…
Reference in New Issue