Merge pull request #1672 from Bilb/data-extraction-notification

Data extraction notification
pull/1682/head
Audric Ackermann 4 years ago committed by GitHub
commit 5fc0849395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1859,5 +1859,23 @@
}, },
"orJoinOneOfThese": { "orJoinOneOfThese": {
"message": "Or join one of these..." "message": "Or join one of these..."
},
"tookAScreenshot": {
"message": "$name$ took a screenshot",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"savedTheFile": {
"message": "Media saved by $name$",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
} }
} }

@ -37,6 +37,8 @@ message Content {
optional ReceiptMessage receiptMessage = 5; optional ReceiptMessage receiptMessage = 5;
optional TypingMessage typingMessage = 6; optional TypingMessage typingMessage = 6;
optional ConfigurationMessage configurationMessage = 7; optional ConfigurationMessage configurationMessage = 7;
optional DataExtractionNotification dataExtractionNotification = 8;
} }
message KeyPair { message KeyPair {
@ -46,6 +48,17 @@ message KeyPair {
required bytes privateKey = 2; required bytes privateKey = 2;
} }
message DataExtractionNotification {
enum Type {
SCREENSHOT = 1; // no way to know this on Desktop
MEDIA_SAVED = 2; // timestamp
}
// @required
required Type type = 1;
optional uint64 timestamp = 2;
}
message DataMessage { message DataMessage {

@ -18,12 +18,20 @@ export interface MediaItemType {
index: number; index: number;
attachment: AttachmentType; attachment: AttachmentType;
message: Message; message: Message;
messageTimestamp: number;
messageSender: string;
} }
type Props = { type Props = {
close: () => void; close: () => void;
media: Array<MediaItemType>; media: Array<MediaItemType>;
onSave?: (options: { attachment: AttachmentType; message: Message; index: number }) => void; onSave?: (options: {
attachment: AttachmentType;
message: Message;
index: number;
messageTimestamp?: number;
messageSender: string;
}) => void;
selectedIndex: number; selectedIndex: number;
}; };
@ -58,11 +66,12 @@ export const LightboxGallery = (props: Props) => {
} }
const mediaItem = media[currentIndex]; const mediaItem = media[currentIndex];
onSave({ onSave({
attachment: mediaItem.attachment, attachment: mediaItem.attachment,
message: mediaItem.message, message: mediaItem.message,
index: mediaItem.index, index: mediaItem.index,
messageTimestamp: mediaItem.messageTimestamp || mediaItem?.message?.sent_at,
messageSender: mediaItem.messageSender || (mediaItem?.message as any)?.source,
}); });
}; };

@ -29,6 +29,41 @@ export const Text = (props: TextProps) => {
return <StyledDefaultText {...props}>{props.text}</StyledDefaultText>; return <StyledDefaultText {...props}>{props.text}</StyledDefaultText>;
}; };
type SpacerProps = {
size: 'lg' | 'md' | 'sm' | 'xs';
theme?: DefaultTheme;
};
const SpacerStyled = styled.div<SpacerProps>`
height: ${props =>
props.size === 'lg'
? props.theme.common.margins.lg
: props.size === 'md'
? props.theme.common.margins.md
: props.size === 'sm'
? props.theme.common.margins.sm
: props.theme.common.margins.xs};
`;
const Spacer = (props: SpacerProps) => {
return <SpacerStyled {...props} />;
};
export const SpacerLG = () => {
return <Spacer size="lg" />;
};
export const SpacerMD = () => {
return <Spacer size="md" />;
};
export const SpacerSM = () => {
return <Spacer size="sm" />;
};
export const SpacerXS = () => {
return <Spacer size="xs" />;
};
type H3Props = { type H3Props = {
text: string; text: string;
opposite?: boolean; opposite?: boolean;

@ -0,0 +1,39 @@
import React from 'react';
import { useTheme } from 'styled-components';
import { DataExtractionNotificationProps } from '../../models/messageType';
import { SignalService } from '../../protobuf';
import { Flex } from '../basic/Flex';
import { SessionIcon, SessionIconSize, SessionIconType } from '../session/icon';
import { SpacerXS, Text } from '../basic/Text';
type Props = DataExtractionNotificationProps;
export const DataExtractionNotification = (props: Props) => {
const theme = useTheme();
const { name, type, source } = props;
let contentText: string;
if (type === SignalService.DataExtractionNotification.Type.MEDIA_SAVED) {
contentText = window.i18n('savedTheFile', name || source);
} else {
contentText = window.i18n('tookAScreenshot', name || source);
}
return (
<Flex
container={true}
flexDirection="column"
alignItems="center"
margin={theme.common.margins.sm}
>
<SessionIcon
iconType={SessionIconType.Upload}
theme={theme}
iconSize={SessionIconSize.Small}
iconRotation={180}
/>
<SpacerXS />
<Text text={contentText} subtle={true} />
</Flex>
);
};

@ -4,4 +4,5 @@ export type Message = {
id: string; id: string;
attachments: Array<Attachment>; attachments: Array<Attachment>;
received_at: number; received_at: number;
sent_at: number;
}; };

@ -13,6 +13,7 @@ import { DefaultTheme } from 'styled-components';
import { UserUtils } from '../../session/utils'; import { UserUtils } from '../../session/utils';
import { ConversationTypeEnum } from '../../models/conversation'; import { ConversationTypeEnum } from '../../models/conversation';
import { SessionJoinableRooms } from './SessionJoinableDefaultRooms'; import { SessionJoinableRooms } from './SessionJoinableDefaultRooms';
import { SpacerLG, SpacerMD } from '../basic/Text';
export enum SessionClosableOverlayType { export enum SessionClosableOverlayType {
Message = 'message', Message = 'message',
@ -165,7 +166,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
/> />
</div> </div>
<div className="spacer-md" /> <SpacerMD />
<h2>{title}</h2> <h2>{title}</h2>
@ -201,7 +202,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
{isClosedGroupView && ( {isClosedGroupView && (
<> <>
<div className="spacer-lg" /> <SpacerLG />
<div className="group-member-list__container"> <div className="group-member-list__container">
{noContactsForClosedGroup ? ( {noContactsForClosedGroup ? (
<div className="group-member-list__no-contacts"> <div className="group-member-list__no-contacts">
@ -214,7 +215,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
)} )}
</div> </div>
<div className="spacer-lg" /> <SpacerLG />
</> </>
)} )}

@ -32,6 +32,7 @@ import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmen
import { deleteOpenGroupMessages } from '../../../interactions/conversation'; import { deleteOpenGroupMessages } from '../../../interactions/conversation';
import { ConversationTypeEnum } from '../../../models/conversation'; import { ConversationTypeEnum } from '../../../models/conversation';
import { updateMentionsMembers } from '../../../state/ducks/mentionsInput'; import { updateMentionsMembers } from '../../../state/ducks/mentionsInput';
import { sendDataExtractionNotification } from '../../../session/messages/outgoing/controlMessage/DataExtractionNotificationMessage';
interface State { interface State {
// Message sending progress // Message sending progress
@ -63,7 +64,12 @@ interface State {
quotedMessageProps?: any; quotedMessageProps?: any;
// lightbox options // lightbox options
lightBoxOptions?: any; lightBoxOptions?: LightBoxOptions;
}
export interface LightBoxOptions {
media: Array<MediaItemType>;
attachment: any;
} }
interface Props { interface Props {
@ -520,7 +526,7 @@ export class SessionConversation extends React.Component<Props, State> {
onRemoveModerators: () => { onRemoveModerators: () => {
window.Whisper.events.trigger('removeModerators', conversation); window.Whisper.events.trigger('removeModerators', conversation);
}, },
onShowLightBox: (lightBoxOptions = {}) => { onShowLightBox: (lightBoxOptions?: LightBoxOptions) => {
this.setState({ lightBoxOptions }); this.setState({ lightBoxOptions });
}, },
}; };
@ -760,14 +766,18 @@ export class SessionConversation extends React.Component<Props, State> {
} }
private onClickAttachment(attachment: any, message: any) { private onClickAttachment(attachment: any, message: any) {
// message is MessageTypeInConvo.propsForMessage I think
const media = (message.attachments || []).map((attachmentForMedia: any) => { const media = (message.attachments || []).map((attachmentForMedia: any) => {
return { return {
objectURL: attachmentForMedia.url, objectURL: attachmentForMedia.url,
contentType: attachmentForMedia.contentType, contentType: attachmentForMedia.contentType,
attachment: attachmentForMedia, attachment: attachmentForMedia,
messageSender: message.authorPhoneNumber,
messageTimestamp: message.direction !== 'outgoing' ? message.timestamp : undefined, // do not set this field when the message was sent from us
// if it is set, this will trigger a sending of DataExtractionNotification to that user, but for an attachment we sent ourself.
}; };
}); });
const lightBoxOptions = { const lightBoxOptions: LightBoxOptions = {
media, media,
attachment, attachment,
}; };
@ -858,7 +868,7 @@ export class SessionConversation extends React.Component<Props, State> {
}); });
} }
private renderLightBox({ media, attachment }: { media: Array<MediaItemType>; attachment: any }) { private renderLightBox({ media, attachment }: LightBoxOptions) {
const selectedIndex = const selectedIndex =
media.length > 1 media.length > 1
? media.findIndex((mediaMessage: any) => mediaMessage.attachment.path === attachment.path) ? media.findIndex((mediaMessage: any) => mediaMessage.attachment.path === attachment.path)
@ -880,10 +890,14 @@ export class SessionConversation extends React.Component<Props, State> {
attachment, attachment,
message, message,
index, index,
messageTimestamp,
messageSender,
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
message?: Message; message?: Message;
index?: number; index?: number;
messageTimestamp?: number;
messageSender: string;
}) { }) {
const { getAbsoluteAttachmentPath } = window.Signal.Migrations; const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType); attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType);
@ -891,8 +905,14 @@ export class SessionConversation extends React.Component<Props, State> {
attachment, attachment,
document, document,
getAbsolutePath: getAbsoluteAttachmentPath, getAbsolutePath: getAbsoluteAttachmentPath,
timestamp: message?.received_at || Date.now(), timestamp: messageTimestamp || message?.received_at,
}); });
await sendDataExtractionNotification(
this.props.selectedConversationKey,
messageSender,
messageTimestamp
);
} }
private async onChoseAttachments(attachmentsFileList: Array<File>) { private async onChoseAttachments(attachmentsFileList: Array<File>) {

@ -20,6 +20,7 @@ import { MessageRegularProps } from '../../../models/messageType';
import { getMessagesBySentAt } from '../../../data/data'; import { getMessagesBySentAt } from '../../../data/data';
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import { ConversationTypeEnum } from '../../../models/conversation'; import { ConversationTypeEnum } from '../../../models/conversation';
import { DataExtractionNotification } from '../../conversation/DataExtractionNotification';
interface State { interface State {
showScrollButton: boolean; showScrollButton: boolean;
@ -45,7 +46,14 @@ interface Props {
replyToMessage: (messageId: number) => Promise<void>; replyToMessage: (messageId: number) => Promise<void>;
showMessageDetails: (messageProps: any) => void; showMessageDetails: (messageProps: any) => void;
onClickAttachment: (attachment: any, message: any) => void; onClickAttachment: (attachment: any, message: any) => void;
onDownloadAttachment: ({ attachment }: { attachment: any }) => void; onDownloadAttachment: ({
attachment,
messageTimestamp,
}: {
attachment: any;
messageTimestamp: number;
messageSender: string;
}) => void;
onDeleteSelectedMessages: () => Promise<void>; onDeleteSelectedMessages: () => Promise<void>;
} }
@ -197,6 +205,7 @@ export class SessionMessagesList extends React.Component<Props, State> {
const timerProps = message.propsForTimerNotification; const timerProps = message.propsForTimerNotification;
const propsForGroupInvitation = message.propsForGroupInvitation; const propsForGroupInvitation = message.propsForGroupInvitation;
const propsForDataExtractionNotification = message.propsForDataExtractionNotification;
const groupNotificationProps = message.propsForGroupNotification; const groupNotificationProps = message.propsForGroupNotification;
@ -236,6 +245,18 @@ export class SessionMessagesList extends React.Component<Props, State> {
); );
} }
if (propsForDataExtractionNotification) {
return (
<>
<DataExtractionNotification
{...propsForDataExtractionNotification}
key={message.id}
/>
{unreadIndicator}
</>
);
}
if (timerProps) { if (timerProps) {
return ( return (
<> <>
@ -305,7 +326,11 @@ export class SessionMessagesList extends React.Component<Props, State> {
this.props.onClickAttachment(attachment, messageProps); this.props.onClickAttachment(attachment, messageProps);
}; };
messageProps.onDownload = (attachment: AttachmentType) => { messageProps.onDownload = (attachment: AttachmentType) => {
this.props.onDownloadAttachment({ attachment }); this.props.onDownloadAttachment({
attachment,
messageTimestamp: messageProps.timestamp,
messageSender: messageProps.authorPhoneNumber,
});
}; };
messageProps.isQuotedMessageToAnimate = messageProps.id === this.state.animateQuotedMessageId; messageProps.isQuotedMessageToAnimate = messageProps.id === this.state.animateQuotedMessageId;

@ -18,6 +18,10 @@ import {
getMessagesWithVisualMediaAttachments, getMessagesWithVisualMediaAttachments,
} from '../../../data/data'; } from '../../../data/data';
import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmentsManager'; import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmentsManager';
import { LightBoxOptions } from './SessionConversation';
import { UserUtils } from '../../../session/utils';
import { sendDataExtractionNotification } from '../../../session/messages/outgoing/controlMessage/DataExtractionNotificationMessage';
import { SpacerLG } from '../../basic/Text';
interface Props { interface Props {
id: string; id: string;
@ -44,7 +48,7 @@ interface Props {
onAddModerators: () => void; onAddModerators: () => void;
onRemoveModerators: () => void; onRemoveModerators: () => void;
onUpdateGroupMembers: () => void; onUpdateGroupMembers: () => void;
onShowLightBox: (options: any) => void; onShowLightBox: (lightboxOptions?: LightBoxOptions) => void;
onSetDisappearingMessages: (seconds: number) => void; onSetDisappearingMessages: (seconds: number) => void;
theme: DefaultTheme; theme: DefaultTheme;
} }
@ -169,7 +173,7 @@ class SessionRightPanel extends React.Component<Props, State> {
}); });
const saveAttachment = async ({ attachment, message }: any = {}) => { const saveAttachment = async ({ attachment, message }: any = {}) => {
const timestamp = message.received_at; const timestamp = message.received_at as number | undefined;
attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType); attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType);
save({ save({
attachment, attachment,
@ -177,6 +181,7 @@ class SessionRightPanel extends React.Component<Props, State> {
getAbsolutePath: window.Signal.Migrations.getAbsoluteAttachmentPath, getAbsolutePath: window.Signal.Migrations.getAbsoluteAttachmentPath,
timestamp, timestamp,
}); });
await sendDataExtractionNotification(this.props.id, message?.source, timestamp);
}; };
const onItemClick = ({ message, attachment, type }: any) => { const onItemClick = ({ message, attachment, type }: any) => {
@ -187,11 +192,17 @@ class SessionRightPanel extends React.Component<Props, State> {
} }
case 'media': { case 'media': {
// don't set the messageTimestamp when we are the sender, so we don't trigger a notification when
// we save the same attachment we sent ourself to another user
const messageTimestamp =
message.source !== UserUtils.getOurPubKeyStrFromCache()
? message.sent_at || message.received_at
: undefined;
const lightBoxOptions = { const lightBoxOptions = {
media, media,
attachment, attachment,
message, messageTimestamp,
}; } as LightBoxOptions;
this.onShowLightBox(lightBoxOptions); this.onShowLightBox(lightBoxOptions);
break; break;
} }
@ -208,8 +219,8 @@ class SessionRightPanel extends React.Component<Props, State> {
}; };
} }
public onShowLightBox(options: any) { public onShowLightBox(lightboxOptions: LightBoxOptions) {
this.props.onShowLightBox(options); this.props.onShowLightBox(lightboxOptions);
} }
// tslint:disable-next-line: cyclomatic-complexity // tslint:disable-next-line: cyclomatic-complexity
@ -260,11 +271,11 @@ class SessionRightPanel extends React.Component<Props, State> {
<h2>{name}</h2> <h2>{name}</h2>
{showMemberCount && ( {showMemberCount && (
<> <>
<div className="spacer-lg" /> <SpacerLG />
<div role="button" className="subtle"> <div role="button" className="subtle">
{window.i18n('members', memberCount)} {window.i18n('members', memberCount)}
</div> </div>
<div className="spacer-lg" /> <SpacerLG />
</> </>
)} )}
<input className="description" placeholder={window.i18n('description')} /> <input className="description" placeholder={window.i18n('description')} />

@ -35,13 +35,6 @@ const getIconDimensionFromIconSize = (iconSize: SessionIconSize | number) => {
} }
}; };
type StyledSvgProps = {
width: string | number;
height: string | number;
iconRotation: number;
rotateDuration?: number;
};
const rotate = keyframes` const rotate = keyframes`
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@ -61,11 +54,17 @@ const animation = (props: { rotateDuration?: any }) => {
} }
}; };
type StyledSvgProps = {
width: string | number;
iconRotation: number;
rotateDuration?: number;
};
//tslint:disable no-unnecessary-callback-wrapper //tslint:disable no-unnecessary-callback-wrapper
const Svg = styled.svg<StyledSvgProps>` const Svg = styled.svg<StyledSvgProps>`
width: ${props => props.width}; width: ${props => props.width};
animation: ${props => animation(props)};
transform: ${props => `rotate(${props.iconRotation}deg)`}; transform: ${props => `rotate(${props.iconRotation}deg)`};
animation: ${props => animation(props)};
`; `;
//tslint:enable no-unnecessary-callback-wrapper //tslint:enable no-unnecessary-callback-wrapper
@ -81,7 +80,13 @@ const SessionSvg = (props: {
}) => { }) => {
const colorSvg = props.iconColor || props?.theme?.colors.textColor; const colorSvg = props.iconColor || props?.theme?.colors.textColor;
const pathArray = props.path instanceof Array ? props.path : [props.path]; const pathArray = props.path instanceof Array ? props.path : [props.path];
const propsToPick = _.pick(props, 'width', 'animation', 'transform') as any; const propsToPick = {
width: props.width,
height: props.height,
rotateDuration: props.rotateDuration,
iconRotation: props.iconRotation,
viewBox: props.viewBox,
};
return ( return (
<Svg {...propsToPick}> <Svg {...propsToPick}>

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Flex } from '../../basic/Flex'; import { Flex } from '../../basic/Flex';
import { SpacerLG } from '../../basic/Text';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../SessionButton'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../SessionButton';
import { SessionSpinner } from '../SessionSpinner'; import { SessionSpinner } from '../SessionSpinner';
import { signInWithLinking, signInWithRecovery, validatePassword } from './RegistrationTabs'; import { signInWithLinking, signInWithRecovery, validatePassword } from './RegistrationTabs';
@ -78,9 +79,9 @@ const SignInButtons = (props: {
return ( return (
<div> <div>
<RestoreUsingRecoveryPhraseButton onRecoveryButtonClicked={props.onRecoveryButtonClicked} /> <RestoreUsingRecoveryPhraseButton onRecoveryButtonClicked={props.onRecoveryButtonClicked} />
<div className="spacer-lg" /> <SpacerLG />
<div className="or">{window.i18n('or')}</div> <div className="or">{window.i18n('or')}</div>
<div className="spacer-lg" /> <SpacerLG />
<LinkDeviceButton onLinkDeviceButtonClicked={props.onLinkDeviceButtonClicked} /> <LinkDeviceButton onLinkDeviceButtonClicked={props.onLinkDeviceButtonClicked} />
</div> </div>
); );

@ -11,6 +11,7 @@ import { ConversationController } from '../../../session/conversations';
import { getConversationLookup, getConversations } from '../../../state/selectors/conversations'; import { getConversationLookup, getConversations } from '../../../state/selectors/conversations';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getPasswordHash } from '../../../../ts/data/data'; import { getPasswordHash } from '../../../../ts/data/data';
import { SpacerLG } from '../../basic/Text';
export enum SessionSettingCategory { export enum SessionSettingCategory {
Appearance = 'appearance', Appearance = 'appearance',
@ -165,7 +166,7 @@ class SettingsViewInner extends React.Component<SettingsViewProps, State> {
{this.state.pwdLockError && ( {this.state.pwdLockError && (
<> <>
<div className="session-label warning">{this.state.pwdLockError}</div> <div className="session-label warning">{this.state.pwdLockError}</div>
<div className="spacer-lg" /> <SpacerLG />
</> </>
)} )}

@ -902,6 +902,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
messageModel: model, messageModel: model,
}) })
); );
const unreadCount = await this.getUnreadCount();
this.set({ unreadCount });
await this.commit();
return model; return model;
} }

@ -11,6 +11,8 @@ import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleM
import { PubKey } from '../../ts/session/types'; import { PubKey } from '../../ts/session/types';
import { UserUtils } from '../../ts/session/utils'; import { UserUtils } from '../../ts/session/utils';
import { import {
DataExtractionNotificationMsg,
DataExtractionNotificationProps,
fillMessageAttributesWithDefaults, fillMessageAttributesWithDefaults,
MessageAttributes, MessageAttributes,
MessageAttributesOptionals, MessageAttributesOptionals,
@ -30,13 +32,14 @@ import {
uploadQuoteThumbnailsV2, uploadQuoteThumbnailsV2,
} from '../session/utils/AttachmentsV2'; } from '../session/utils/AttachmentsV2';
import { acceptOpenGroupInvitation } from '../interactions/message'; import { acceptOpenGroupInvitation } from '../interactions/message';
import { OpenGroupMessageV2 } from '../opengroup/opengroupV2/OpenGroupMessageV2';
import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { getV2OpenGroupRoom } from '../data/opengroups'; import { getV2OpenGroupRoom } from '../data/opengroups';
export class MessageModel extends Backbone.Model<MessageAttributes> { export class MessageModel extends Backbone.Model<MessageAttributes> {
public propsForTimerNotification: any; public propsForTimerNotification: any;
public propsForGroupNotification: any; public propsForGroupNotification: any;
public propsForGroupInvitation: any; public propsForGroupInvitation: any;
public propsForDataExtractionNotification?: DataExtractionNotificationProps;
public propsForSearchResult: any; public propsForSearchResult: any;
public propsForMessage: any; public propsForMessage: any;
@ -75,6 +78,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
this.propsForGroupNotification = this.getPropsForGroupNotification(); this.propsForGroupNotification = this.getPropsForGroupNotification();
} else if (this.isGroupInvitation()) { } else if (this.isGroupInvitation()) {
this.propsForGroupInvitation = this.getPropsForGroupInvitation(); this.propsForGroupInvitation = this.getPropsForGroupInvitation();
} else if (this.isDataExtractionNotification()) {
this.propsForDataExtractionNotification = this.getPropsForDataExtractionNotification();
} else { } else {
this.propsForSearchResult = this.getPropsForSearchResult(); this.propsForSearchResult = this.getPropsForSearchResult();
this.propsForMessage = this.getPropsForMessage(); this.propsForMessage = this.getPropsForMessage();
@ -193,6 +198,26 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (this.isGroupInvitation()) { if (this.isGroupInvitation()) {
return `😎 ${window.i18n('openGroupInvitation')}`; return `😎 ${window.i18n('openGroupInvitation')}`;
} }
if (this.isDataExtractionNotification()) {
const dataExtraction = this.get(
'dataExtractionNotification'
) as DataExtractionNotificationMsg;
if (dataExtraction.type === SignalService.DataExtractionNotification.Type.SCREENSHOT) {
return window.i18n(
'tookAScreenshot',
ConversationController.getInstance().getContactProfileNameOrShortenedPubKey(
dataExtraction.source
)
);
}
return window.i18n(
'savedTheFile',
ConversationController.getInstance().getContactProfileNameOrShortenedPubKey(
dataExtraction.source
)
);
}
return this.get('body'); return this.get('body');
} }
@ -200,6 +225,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return !!this.get('groupInvitation'); return !!this.get('groupInvitation');
} }
public isDataExtractionNotification() {
return !!this.get('dataExtractionNotification');
}
public getNotificationText() { public getNotificationText() {
let description = this.getDescription(); let description = this.getDescription();
if (description) { if (description) {
@ -305,6 +334,22 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}; };
} }
public getPropsForDataExtractionNotification(): DataExtractionNotificationProps | undefined {
const dataExtractionNotification = this.get('dataExtractionNotification');
if (!dataExtractionNotification) {
window.log.warn('dataExtractionNotification should not happen');
return;
}
const contact = this.findAndFormatContact(dataExtractionNotification.source);
return {
...dataExtractionNotification,
name: contact.profileName || contact.name || dataExtractionNotification.source,
};
}
public findContact(pubkey: string) { public findContact(pubkey: string) {
return ConversationController.getInstance().get(pubkey); return ConversationController.getInstance().get(pubkey);
} }
@ -414,6 +459,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return null; return null;
} }
if (this.isDataExtractionNotification()) {
return null;
}
const readBy = this.get('read_by') || []; const readBy = this.get('read_by') || [];
if (window.storage.get('read-receipt-setting') && readBy.length > 0) { if (window.storage.get('read-receipt-setting') && readBy.length > 0) {
return 'read'; return 'read';

@ -98,8 +98,24 @@ export interface MessageAttributes {
*/ */
snippet?: any; snippet?: any;
direction: any; direction: any;
/**
* This is used for when a user screenshots or saves an attachment you sent.
* We display a small message just below the message referenced
*/
dataExtractionNotification?: DataExtractionNotificationMsg;
}
export interface DataExtractionNotificationMsg {
type: number; // screenshot or saving event, based on SignalService.DataExtractionNotification.Type
source: string; // the guy who made a screenshot
referencedAttachmentTimestamp: number; // the attachment timestamp he screenshot
} }
export type DataExtractionNotificationProps = DataExtractionNotificationMsg & {
name: string;
};
export interface MessageAttributesOptionals { export interface MessageAttributesOptionals {
id?: string; id?: string;
source?: string; source?: string;
@ -134,6 +150,11 @@ export interface MessageAttributesOptionals {
source: string; source: string;
fromSync?: boolean; fromSync?: boolean;
}; };
dataExtractionNotification?: {
type: number;
source: string;
referencedAttachmentTimestamp: number;
};
unread?: number; unread?: number;
group?: any; group?: any;
timestamp?: number; timestamp?: number;

@ -342,7 +342,7 @@ export async function innerHandleContentMessage(
return; return;
} }
if (content.typingMessage) { if (content.typingMessage) {
await handleTypingMessage(envelope, content.typingMessage); await handleTypingMessage(envelope, content.typingMessage as SignalService.TypingMessage);
return; return;
} }
if (content.configurationMessage) { if (content.configurationMessage) {
@ -353,6 +353,13 @@ export async function innerHandleContentMessage(
); );
return; return;
} }
if (content.dataExtractionNotification) {
await handleDataExtractionNotification(
envelope,
content.dataExtractionNotification as SignalService.DataExtractionNotification
);
return;
}
} catch (e) { } catch (e) {
window?.log?.warn(e); window?.log?.warn(e);
} }
@ -422,17 +429,18 @@ async function handleReceiptMessage(
async function handleTypingMessage( async function handleTypingMessage(
envelope: EnvelopePlus, envelope: EnvelopePlus,
iTypingMessage: SignalService.ITypingMessage typingMessage: SignalService.TypingMessage
): Promise<void> { ): Promise<void> {
const ev = new Event('typing');
const typingMessage = iTypingMessage as SignalService.TypingMessage;
const { timestamp, action } = typingMessage; const { timestamp, action } = typingMessage;
const { source } = envelope; const { source } = envelope;
await removeFromCache(envelope); await removeFromCache(envelope);
// We don't do anything with incoming typing messages if the setting is disabled
if (!window.storage.get('typing-indicators-setting')) {
return;
}
if (envelope.timestamp && timestamp) { if (envelope.timestamp && timestamp) {
const envelopeTimestamp = Lodash.toNumber(envelope.timestamp); const envelopeTimestamp = Lodash.toNumber(envelope.timestamp);
const typingTimestamp = Lodash.toNumber(timestamp); const typingTimestamp = Lodash.toNumber(timestamp);
@ -445,11 +453,6 @@ async function handleTypingMessage(
} }
} }
// We don't do anything with incoming typing messages if the setting is disabled
if (!window.storage.get('typing-indicators-setting')) {
return;
}
// typing message are only working with direct chats/ not groups // typing message are only working with direct chats/ not groups
const conversation = ConversationController.getInstance().get(source); const conversation = ConversationController.getInstance().get(source);
@ -462,3 +465,52 @@ async function handleTypingMessage(
}); });
} }
} }
/**
* A DataExtractionNotification message can only come from a 1 o 1 conversation.
*
* We drop them if the convo is not a 1 o 1 conversation.
*/
export async function handleDataExtractionNotification(
envelope: EnvelopePlus,
dataNotificationMessage: SignalService.DataExtractionNotification
): Promise<void> {
// we currently don't care about the timestamp included in the field itself, just the timestamp of the envelope
const { type, timestamp: referencedAttachment } = dataNotificationMessage;
const { source, timestamp } = envelope;
await removeFromCache(envelope);
const convo = ConversationController.getInstance().get(source);
if (!convo || !convo.isPrivate()) {
window?.log?.info('Got DataNotification for unknown or non private convo');
return;
}
if (!type || !source) {
window?.log?.info('DataNotification pre check failed');
return;
}
if (timestamp) {
const envelopeTimestamp = Lodash.toNumber(timestamp);
const referencedAttachmentTimestamp = Lodash.toNumber(referencedAttachment);
const now = Date.now();
await convo.addSingleMessage({
conversationId: convo.get('id'),
type: 'outgoing', // mark it as outgoing just so it appears below our sent attachment
sent_at: envelopeTimestamp,
received_at: now,
dataExtractionNotification: {
type,
referencedAttachmentTimestamp, // currently unused
source,
},
unread: 1, // 1 means unread
expireTimer: 0,
});
convo.updateLastMessage();
}
}

@ -2,7 +2,6 @@
import { SignalService } from '../../../../protobuf'; import { SignalService } from '../../../../protobuf';
import { MessageParams } from '../Message'; import { MessageParams } from '../Message';
import { Constants } from '../../..';
import { ECKeyPair } from '../../../../receiver/keypairs'; import { ECKeyPair } from '../../../../receiver/keypairs';
import { fromHexToArray } from '../../../utils/String'; import { fromHexToArray } from '../../../utils/String';
import { PubKey } from '../../../types'; import { PubKey } from '../../../types';

@ -0,0 +1,78 @@
import { SignalService } from '../../../../protobuf';
import { MessageParams } from '../Message';
import { ContentMessage } from '..';
import { v4 as uuid } from 'uuid';
import { PubKey } from '../../../types';
import { getMessageQueue } from '../../..';
import { ConversationController } from '../../../conversations';
import { UserUtils } from '../../../utils';
interface DataExtractionNotificationMessageParams extends MessageParams {
referencedAttachmentTimestamp: number;
}
export class DataExtractionNotificationMessage extends ContentMessage {
public readonly referencedAttachmentTimestamp: number;
constructor(params: DataExtractionNotificationMessageParams) {
super({ timestamp: params.timestamp, identifier: params.identifier });
this.referencedAttachmentTimestamp = params.referencedAttachmentTimestamp;
// this does not make any sense
if (!this.referencedAttachmentTimestamp) {
throw new Error('referencedAttachmentTimestamp must be set');
}
}
public contentProto(): SignalService.Content {
return new SignalService.Content({
dataExtractionNotification: this.dataExtractionProto(),
});
}
protected dataExtractionProto(): SignalService.DataExtractionNotification {
const ACTION_ENUM = SignalService.DataExtractionNotification.Type;
const action = ACTION_ENUM.MEDIA_SAVED; // we cannot know when user screenshots, so it can only be a media saved
return new SignalService.DataExtractionNotification({
type: action,
timestamp: this.referencedAttachmentTimestamp,
});
}
}
/**
* Currently only enabled for private chats
*/
export const sendDataExtractionNotification = async (
conversationId: string,
attachmentSender: string,
referencedAttachmentTimestamp?: number
) => {
const convo = ConversationController.getInstance().get(conversationId);
if (
!convo ||
!convo.isPrivate() ||
convo.isMe() ||
UserUtils.isUsFromCache(PubKey.cast(attachmentSender)) ||
!referencedAttachmentTimestamp
) {
window.log.warn('Not sending saving attachment notification for', attachmentSender);
return;
}
const dataExtractionNotificationMessage = new DataExtractionNotificationMessage({
referencedAttachmentTimestamp,
identifier: uuid(),
timestamp: Date.now(),
});
const pubkey = PubKey.cast(conversationId);
window.log.info(
`Sending DataExtractionNotification to ${conversationId} about attachment: ${referencedAttachmentTimestamp}`
);
try {
await getMessageQueue().sendToPubKey(pubkey, dataExtractionNotificationMessage);
} catch (e) {
window.log.warn('failed to send data extraction notification', e);
}
};

@ -413,7 +413,10 @@ export async function retrieveNextMessages(
return []; return [];
} }
} catch (e) { } catch (e) {
window?.log?.warn('Got an error while retrieving next messages:', e); window?.log?.warn(
'Got an error while retrieving next messages. Not retrying as we trigger fetch often:',
e
);
return []; return [];
} }
} }

@ -198,14 +198,18 @@ async function getSnodeListFromLokidSeednode(
window?.log?.warn('loki_snode_api::getSnodeListFromLokidSeednode - error', e.code, e.message); window?.log?.warn('loki_snode_api::getSnodeListFromLokidSeednode - error', e.code, e.message);
// handle retries in case of temporary hiccups // handle retries in case of temporary hiccups
if (retries < SEED_NODE_RETRIES) { if (retries < SEED_NODE_RETRIES) {
setTimeout(() => { setTimeout(async () => {
window?.log?.info( window?.log?.info(
'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #', 'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
retries, retries,
'seed nodes total', 'seed nodes total',
seedNodes.length seedNodes.length
); );
void getSnodeListFromLokidSeednode(seedNodes, retries + 1); try {
await getSnodeListFromLokidSeednode(seedNodes, retries + 1);
} catch (e) {
window?.log?.warn('getSnodeListFromLokidSeednode failed retr y #', retries, e);
}
}, retries * retries * 5000); }, retries * retries * 5000);
} else { } else {
window?.log?.error('loki_snode_api::getSnodeListFromLokidSeednode - failing'); window?.log?.error('loki_snode_api::getSnodeListFromLokidSeednode - failing');

@ -427,8 +427,8 @@ const toPickFromMessageModel = [
'firstMessageOfSeries', 'firstMessageOfSeries',
'propsForGroupInvitation', 'propsForGroupInvitation',
'propsForTimerNotification', 'propsForTimerNotification',
'propsForVerificationNotification',
'propsForGroupNotification', 'propsForGroupNotification',
'propsForDataExtractionNotification',
// FIXME below are what is needed to fetch on the fly messageDetails. This is not the react way // FIXME below are what is needed to fetch on the fly messageDetails. This is not the react way
'getPropsForMessageDetail', 'getPropsForMessageDetail',
'get', 'get',

@ -7,6 +7,10 @@ import {
Section, Section,
} from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; } from '../../../components/conversation/media-gallery/groupMediaItemsByDate';
import { MediaItemType } from '../../../components/LightboxGallery'; import { MediaItemType } from '../../../components/LightboxGallery';
import { TestUtils } from '../../test-utils';
const generatedMessageSenderKey = TestUtils.generateFakePubKey().key;
const generatedMessageTimestamp = Date.now();
const toMediaItem = (date: Date): MediaItemType => ({ const toMediaItem = (date: Date): MediaItemType => ({
objectURL: date.toUTCString(), objectURL: date.toUTCString(),
@ -14,6 +18,7 @@ const toMediaItem = (date: Date): MediaItemType => ({
message: { message: {
id: 'id', id: 'id',
received_at: date.getTime(), received_at: date.getTime(),
sent_at: date.getTime(),
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -22,6 +27,8 @@ const toMediaItem = (date: Date): MediaItemType => ({
url: 'url', url: 'url',
}, },
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}); });
// tslint:disable: max-func-body-length // tslint:disable: max-func-body-length
@ -57,6 +64,7 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
sent_at: 1523534400000,
received_at: 1523534400000, received_at: 1523534400000,
attachments: [], attachments: [],
}, },
@ -65,6 +73,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
{ {
objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT', objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT',
@ -73,6 +83,7 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1523491260000, received_at: 1523491260000,
sent_at: 1523491260000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -80,6 +91,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
], ],
}, },
@ -93,6 +106,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1523491140000, received_at: 1523491140000,
sent_at: 1523491140000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -100,6 +115,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
], ],
}, },
@ -113,6 +130,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1523232060000, received_at: 1523232060000,
sent_at: 1523232060000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -120,6 +139,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
], ],
}, },
@ -133,6 +154,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1523231940000, received_at: 1523231940000,
sent_at: 1523231940000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -140,6 +163,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
{ {
objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT', objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT',
@ -147,6 +172,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1522540860000, received_at: 1522540860000,
sent_at: 1522540860000,
attachments: [], attachments: [],
}, },
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
@ -155,6 +182,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
], ],
}, },
@ -170,6 +199,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1522540740000, received_at: 1522540740000,
sent_at: 1522540740000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -177,6 +208,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
{ {
objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT', objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT',
@ -185,6 +218,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1519912800000, received_at: 1519912800000,
sent_at: 1519912800000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -192,6 +227,8 @@ describe('groupMediaItemsByDate', () => {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: 'url',
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
], ],
}, },
@ -206,6 +243,8 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1298937540000, received_at: 1298937540000,
sent_at: 1298937540000,
attachments: [], attachments: [],
}, },
attachment: { attachment: {
@ -214,6 +253,8 @@ describe('groupMediaItemsByDate', () => {
url: 'url', url: 'url',
}, },
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
}, },
{ {
objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT', objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT',
@ -222,8 +263,12 @@ describe('groupMediaItemsByDate', () => {
message: { message: {
id: 'id', id: 'id',
received_at: 1296554400000, received_at: 1296554400000,
sent_at: 1296554400000,
attachments: [], attachments: [],
}, },
messageSender: generatedMessageSenderKey,
messageTimestamp: generatedMessageTimestamp,
attachment: { attachment: {
fileName: 'fileName', fileName: 'fileName',
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,

Loading…
Cancel
Save