Merge pull request #3042 from oxen-io/clearnet

Session 1.12.0
master
Audric Ackermann 4 months ago committed by GitHub
commit daec9fc8c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,6 +18,8 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
],
plugins: ['mocha', 'more', '@typescript-eslint'],
@ -67,6 +69,12 @@ module.exports = {
'@typescript-eslint/array-type': ['error', { default: 'generic' }],
'@typescript-eslint/no-misused-promises': 'error',
// make imports without file extensions
'import/extensions': ['warn', 'never'],
// NOTE Remove this line when debugging cyclic dependencies
'import/no-cycle': 'off',
// Prettier overrides:
'arrow-parens': 'off',
'no-nested-ternary': 'off',

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -41,7 +41,7 @@
"unreadMessages": "Unread Messages",
"debugLogExplanation": "This log will be saved to your desktop.",
"reportIssue": "Report a Bug",
"markAllAsRead": "Mark All as Read",
"markAllAsRead": "Mark Read",
"incomingError": "Error handling incoming message",
"media": "Media",
"mediaEmptyState": "No media",
@ -79,7 +79,7 @@
"typingAlt": "Typing animation for this conversation",
"contactAvatarAlt": "Avatar for contact $name$",
"downloadAttachment": "Download Attachment",
"replyToMessage": "Reply to message",
"replyToMessage": "Reply",
"replyingToMessage": "Replying to:",
"originalMessageNotFound": "Original message not found",
"you": "You",
@ -97,24 +97,35 @@
"continue": "Continue",
"error": "Error",
"delete": "Delete",
"hide": "Hide",
"messageDeletionForbidden": "You dont have permission to delete others messages",
"deleteJustForMe": "Delete just for me",
"deleteForEveryone": "Delete for everyone",
"deleteMessagesQuestion": "Delete $count$ messages?",
"deleteMessageQuestion": "Delete this message?",
"deleteMessages": "Delete Messages",
"deleteMessagesConfirmation": "Permanently delete the messages in this conversation?",
"hideConversation": "Hide Conversation",
"hideNoteToSelfConfirmation": "Are you sure you want to hide your <b>Note to Self</b> conversation?",
"deleteConversation": "Delete Conversation",
"deleteConversationConfirmation": "Are you sure you want to delete your conversation with <b>$name$</b>?",
"deleteConversationFailed": "Failed to delete the Conversation!",
"deleteConversationFailedPleaseTryAgain": "Unable to delete the conversation, please try again",
"leaving": "Leaving...",
"deleted": "$count$ deleted",
"messageDeletedPlaceholder": "This message has been deleted",
"from": "From:",
"to": "To:",
"sent": "Sent",
"sending": "Sending",
"received": "Received",
"sendMessage": "Message",
"groupMembers": "Members",
"moreInformation": "More information",
"failed": "Failed",
"failedToSendMessage": "Failed to send message",
"read": "Read",
"resend": "Resend",
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
"clear": "Clear",
"clearAllData": "Clear All Data",
"deleteAccountWarning": "This will permanently delete your messages and contacts.",
@ -181,9 +192,24 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"timer": "Timer",
"timerModeRead": "read",
"timerModeSent": "sent",
"confirm": "Confirm",
"messageHash": "Message Hash",
"serverId": "Server ID",
"expirationType": "Expiration Type",
"expirationDuration": "Expiration Duration",
"disappears": "Disappears",
"messageWillDisappear": "Messages will disappear in $countAndUnit$",
"followSetting": "Follow Setting",
"followSettingDisabled": "Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?",
"followSettingTimeAndType": "Set your messages to disappear <b>$time$</b> after they have been <b>$type$</b>?",
"youChangedTheTimer": "<b>You</b> have set messages to disappear <b>$time$</b> after they have been <b>$mode$</b>",
"youChangedTheTimerLegacy": "<b>You</b> set the disappearing message timer to <b>$time$</b>",
"theyChangedTheTimer": "<b>$name$</b> has set messages to disappear <b>$time$</b> after they have been <b>$mode$</b>",
"theyChangedTheTimerLegacy": "<b>$name$</b> set the disappearing message timer to <b>$time$</b>",
"timerOption_0_seconds": "Off",
"timerOption_5_seconds": "5 seconds",
"timerOption_10_seconds": "10 seconds",
@ -197,11 +223,6 @@
"timerOption_1_day": "1 day",
"timerOption_1_week": "1 week",
"timerOption_2_weeks": "2 weeks",
"disappearingMessages": "Disappearing messages",
"changeNickname": "Change Nickname",
"clearNickname": "Clear Nickname",
"nicknamePlaceholder": "New Nickname",
"changeNicknameMessage": "Enter a nickname for this user",
"timerOption_0_seconds_abbreviated": "off",
"timerOption_5_seconds_abbreviated": "5s",
"timerOption_10_seconds_abbreviated": "10s",
@ -215,10 +236,29 @@
"timerOption_1_day_abbreviated": "1d",
"timerOption_1_week_abbreviated": "1w",
"timerOption_2_weeks_abbreviated": "2w",
"disappearingMessages": "Disappearing messages",
"disappearingMessagesModeOutdated": "$name$ is using an outdated client. Disappearing messages may not work as expected.",
"disappearingMessagesModeLabel": "Delete Type",
"disappearingMessagesModeOff": "Off",
"disappearingMessagesModeAfterRead": "Disappear After Read",
"disappearingMessagesModeAfterReadSubtitle": "Messages delete after they have been read.",
"disappearingMessagesModeAfterSend": "Disappear After Send",
"disappearingMessagesModeAfterSendSubtitle": "Messages delete after they have been sent.",
"disappearingMessagesModeLegacy": "Legacy",
"disappearingMessagesModeLegacySubtitle": "Original version of disappearing messages.",
"disappearingMessagesDisabled": "Disappearing messages disabled",
"disabledDisappearingMessages": "$name$ has turned off disappearing messages.",
"youDisabledDisappearingMessages": "You disabled disappearing messages.",
"disabledDisappearingMessages": "<b>$name$</b> has turned <b>off</b> disappearing messages.",
"youDisabledDisappearingMessages": "<b>You</b> have turned <b>off</b> disappearing messages.",
"youDisabledYourDisappearingMessages": "<b>You</b> turned <b>off</b> disappearing messages. Messages you send will no longer disappear.",
"youSetYourDisappearingMessages": "<b>You</b> set your messages to disappear <b>$time$</b> after they have been <b>$type$</b>.",
"theyDisabledTheirDisappearingMessages": "<b>$name$</b> has turned <b>off</b> disappearing messages. Messages they send will no longer disappear.",
"theySetTheirDisappearingMessages": "<b>$name$</b> has set their messages to disappear <b>$time$</b> after they have been <b>$type$</b>.",
"timerSetTo": "Disappearing message time set to $time$",
"set": "Set",
"changeNickname": "Change Nickname",
"clearNickname": "Clear Nickname",
"nicknamePlaceholder": "New Nickname",
"changeNicknameMessage": "Enter a nickname for this user",
"noteToSelf": "Note to Self",
"savedMessages": "Saved Messages",
"hideMenuBarTitle": "Hide Menu Bar",
@ -259,10 +299,19 @@
"banUserAndDeleteAll": "Ban and Delete All",
"userBanned": "Banned successfully",
"userBanFailed": "Ban failed!",
"leave": "Leave",
"leaveGroup": "Leave Group",
"leaveAndRemoveForEveryone": "Leave Group and Remove for Everyone",
"leaveGroupConfirmation": "Are you sure you want to leave this group?",
"leaveGroupConfirmation": "Are you sure you want to leave <b>$name$</b>?",
"leaveGroupConfirmationAdmin": "As you are the admin of this group, if you leave it it will be removed for every current members. Are you sure you want to leave this group?",
"leaveGroupConrirmationOnlyAdminLegacy": "Are you sure you want to leave <b>$name$</b>? This will deactivate the group for all members.",
"leaveGroupConfirmationOnlyAdmin": "You are the only admin in <b>$name$</b>",
"leaveGroupConfirmationOnlyAdminWarning": "Group settings and members cannot be changed without an admin",
"leaveGroupFailed": "Failed to leave Group!",
"leaveGroupFailedPleaseTryAgain": "Unable to leave the Group, please try again",
"leaveCommunity": "Leave Community",
"leaveCommunityFailed": "Failed to leave Community!",
"leaveCommunityFailedPleaseTryAgain": "Unable to leave the Community, please try again",
"cannotRemoveCreatorFromGroup": "Cannot remove this user",
"cannotRemoveCreatorFromGroupDesc": "You cannot remove this user as they are the creator of the group.",
"noContactsForGroup": "You don't have any contacts yet",
@ -325,6 +374,7 @@
"editProfileModalTitle": "Profile",
"groupNamePlaceholder": "Group Name",
"inviteContacts": "Invite Contacts",
"addModerator": "Add Admin",
"addModerators": "Add Admins",
"removeModerators": "Remove Admins",
"addAsModerator": "Add as Admin",
@ -517,5 +567,16 @@
"reactionPopupMany": "$name$, $name2$, $name3$ &",
"reactionListCountSingular": "And $otherSingular$ has reacted <span>$emoji$</span> to this message",
"reactionListCountPlural": "And $otherPlural$ have reacted <span>$emoji$</span> to this message",
"setDisplayPicture": "Set Display Picture"
"setDisplayPicture": "Set Display Picture",
"settingAppliesToEveryone": "This setting applies to everyone in this conversation.",
"settingAppliesToYourMessages": "This setting applies to messages you send in this conversation.",
"onlyGroupAdminsCanChange": "Only group admins can change this setting.",
"messageInfo": "Message Info",
"fileId": "File ID",
"fileSize": "File Size",
"fileType": "File Type",
"resolution": "Resolution",
"duration": "Duration",
"notApplicable": "N/A",
"unknownError": "Unknown Error"
}

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -181,6 +181,7 @@
"messageBodyMissing": "Please enter a message body.",
"messageBody": "Message body",
"unblockToSend": "Unblock this contact to send a message.",
"unblockGroupToSend": "This group is blocked. Unblock it if you would like to send a message.",
"youChangedTheTimer": "You set the disappearing message timer to $time$",
"timerSetOnSync": "Updated disappearing message timer to $time$",
"theyChangedTheTimer": "$name$ set the disappearing message timer to $time$",

@ -2,7 +2,7 @@
"name": "session-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"version": "1.11.5",
"version": "1.12.0",
"license": "GPL-3.0",
"author": {
"name": "Oxen Labs",
@ -79,12 +79,13 @@
"blob-util": "2.0.2",
"blueimp-load-image": "5.14.0",
"buffer-crc32": "0.2.13",
"bunyan": "^1.8.15",
"bunyan": "https://github.com/Bilb/node-bunyan",
"bytebuffer": "^5.0.1",
"classnames": "2.2.5",
"config": "1.28.1",
"country-code-lookup": "^0.0.19",
"curve25519-js": "https://github.com/oxen-io/curve25519-js",
"date-fns": "^3.3.1",
"dompurify": "^2.0.7",
"electron-localshortcut": "^3.2.1",
"electron-updater": "^4.2.2",
@ -95,7 +96,7 @@
"glob": "7.1.2",
"image-type": "^4.1.0",
"ip2country": "1.0.1",
"libsession_util_nodejs": "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.2.6/libsession_util_nodejs-v0.2.6.tar.gz",
"libsession_util_nodejs": "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.3.1/libsession_util_nodejs-v0.3.1.tar.gz",
"libsodium-wrappers-sumo": "^0.7.9",
"linkify-it": "^4.0.1",
"lodash": "^4.17.21",
@ -113,7 +114,7 @@
"react-dom": "^17.0.2",
"react-draggable": "^4.4.4",
"react-h5-audio-player": "^3.2.0",
"react-intersection-observer": "^8.30.3",
"react-intersection-observer": "^9.7.0",
"react-mentions": "^4.4.9",
"react-qr-svg": "^2.2.1",
"react-redux": "8.0.4",
@ -180,6 +181,7 @@
"eslint": "^8.45.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-more": "^1.0.5",

@ -1,43 +0,0 @@
diff --git a/node_modules/bunyan/lib/bunyan.js b/node_modules/bunyan/lib/bunyan.js
index f988560..a4cf69a 100644
--- a/node_modules/bunyan/lib/bunyan.js
+++ b/node_modules/bunyan/lib/bunyan.js
@@ -63,7 +63,7 @@ if (!runtimeEnv) {
}
-var os, fs, dtrace;
+var os, fs, pathModule, dtrace;
if (runtimeEnv === 'browser') {
os = {
hostname: function () {
@@ -71,12 +71,15 @@ if (runtimeEnv === 'browser') {
}
};
fs = {};
+ pathModule = {};
dtrace = null;
} else {
os = require('os');
fs = require('fs');
+ pathModule = require('path');
try {
- dtrace = require('dtrace-provider' + '');
+ throw new Error('dtrace-provider is not available')
+ // dtrace = require('dtrace-provider' + '');
} catch (e) {
dtrace = null;
}
@@ -1512,6 +1515,12 @@ RotatingFileStream.prototype.rotate = function rotate() {
}
function finish() {
+ if (!fs.existsSync(self.path)) {
+ var dirPath = pathModule.dirname(self.path);
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
+ }
+ }
self._debug(' open %s', self.path);
self.stream = fs.createWriteStream(self.path,
{flags: 'a', encoding: 'utf8'});

@ -2,6 +2,8 @@
const { clipboard, ipcRenderer, webFrame } = require('electron/main');
const { Storage } = require('./ts/util/storage');
const { isTestNet, isTestIntegration } = require('./ts/shared/env_vars');
const url = require('url');
const _ = require('lodash');
@ -22,19 +24,14 @@ window.getTitle = () => title;
window.getEnvironment = () => configAny.environment;
window.getAppInstance = () => configAny.appInstance;
window.getVersion = () => configAny.version;
window.isDev = () => config.environment === 'development';
window.getCommitHash = () => configAny.commitHash;
window.getNodeVersion = () => configAny.node_version;
window.sessionFeatureFlags = {
useOnionRequests: true,
useTestNet: Boolean(
process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('testnet')
),
integrationTestEnv: Boolean(
process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('test-integration')
),
useClosedGroupV3: false || process.env.USE_CLOSED_GROUP_V3,
useTestNet: isTestNet(),
integrationTestEnv: isTestIntegration(),
useClosedGroupV3: false,
debug: {
debugLogging: !_.isEmpty(process.env.SESSION_DEBUG),
debugLibsessionDumps: !_.isEmpty(process.env.SESSION_DEBUG_LIBSESSION_DUMPS),

@ -18,10 +18,12 @@ message Envelope {
}
message TypingMessage {
enum Action {
STARTED = 0;
STOPPED = 1;
}
required uint64 timestamp = 1;
required Action action = 2;
}
@ -55,6 +57,13 @@ message SharedConfigMessage {
}
message Content {
enum ExpirationType {
UNKNOWN = 0;
DELETE_AFTER_READ = 1;
DELETE_AFTER_SEND = 2;
}
optional DataMessage dataMessage = 1;
optional CallMessage callMessage = 3;
optional ReceiptMessage receiptMessage = 5;
@ -64,6 +73,8 @@ message Content {
optional Unsend unsendMessage = 9;
optional MessageRequestResponse messageRequestResponse = 10;
optional SharedConfigMessage sharedConfigMessage = 11;
optional ExpirationType expirationType = 12;
optional uint32 expirationTimer = 13;
}
message KeyPair {
@ -179,8 +190,8 @@ message DataMessage {
message ClosedGroupControlMessage {
enum Type {
NEW = 1;
enum Type {
NEW = 1;
ENCRYPTION_KEY_PAIR = 3;
NAME_CHANGE = 4;
MEMBERS_ADDED = 5;
@ -189,31 +200,30 @@ message DataMessage {
ENCRYPTION_KEY_PAIR_REQUEST = 8;
}
message KeyPairWrapper {
// @required
required bytes publicKey = 1; // The public key of the user the key pair is meant for
// @required
required bytes encryptedKeyPair = 2; // The encrypted key pair
}
// @required
required Type type = 1;
optional bytes publicKey = 2;
optional string name = 3;
optional KeyPair encryptionKeyPair = 4;
repeated bytes members = 5;
repeated bytes admins = 6;
repeated KeyPairWrapper wrappers = 7;
optional uint32 expireTimer = 8;
}
message KeyPairWrapper {
// @required
required bytes publicKey = 1; // The public key of the user the key pair is meant for
// @required
required bytes encryptedKeyPair = 2; // The encrypted key pair
}
// @required
required Type type = 1;
optional bytes publicKey = 2;
optional string name = 3;
optional KeyPair encryptionKeyPair = 4;
repeated bytes members = 5;
repeated bytes admins = 6;
repeated KeyPairWrapper wrappers = 7;
optional uint32 expirationTimer = 8;
}
optional string body = 1;
repeated AttachmentPointer attachments = 2;
optional GroupContext group = 3;
optional uint32 flags = 4;
// TODO legacy messages support will be removed in a future release
optional uint32 expireTimer = 5;
optional bytes profileKey = 6;
optional uint64 timestamp = 7;
@ -352,4 +362,4 @@ message WebSocketMessage {
optional Type type = 1;
optional WebSocketRequestMessage request = 2;
}
}

@ -1,79 +0,0 @@
.message-detail-wrapper {
height: calc(100% - 48px);
width: 100%;
overflow-y: auto;
z-index: 2;
}
.public-chat-message-wrapper {
padding-inline-start: 10px;
padding-inline-end: 10px;
}
.group-invitation-container {
display: flex;
flex-direction: column;
}
.group-invitation {
background-color: var(--message-bubbles-received-background-color);
&.invitation-outgoing {
background-color: var(--message-bubbles-sent-background-color);
align-self: flex-end;
.contents {
.session-icon-button {
background-color: var(--transparent-color);
}
}
}
display: inline-block;
margin: 4px 16px;
padding: 4px;
border-radius: var(--border-radius-message-box);
align-self: flex-start;
box-shadow: none;
.contents {
display: flex;
align-items: center;
margin: 6px;
.invite-group-avatar {
height: 48px;
width: 48px;
}
.group-details {
display: inline-flex;
flex-direction: column;
padding: 0px 12px;
.group-name {
font-weight: bold;
font-size: 18px;
}
}
.session-icon-button {
background-color: var(--primary-color);
}
}
}
.group-invitation {
.group-details {
color: var(--message-bubbles-received-text-color);
}
}
.group-invitation.invitation-outgoing {
.group-details {
color: var(--message-bubbles-sent-text-color);
}
}

@ -23,10 +23,6 @@
.module-contact-name.compact {
display: block;
span::after {
content: '\00a0';
}
}
// Module: Message
@ -328,7 +324,6 @@
display: inline-flex;
flex-direction: row;
align-items: center;
height: 48px;
max-width: 100%;
}
@ -343,7 +338,7 @@
min-width: 0;
font-size: 16px;
line-height: 24px;
line-height: 26px;
font-weight: 400;
color: var(--text-primary-color);
@ -366,86 +361,6 @@
}
}
.module-conversation-header__expiration {
display: flex;
flex-direction: row;
align-items: center;
padding-inline-start: 8px;
padding-inline-end: 8px;
flex-shrink: 0;
}
.module-conversation-header__expiration__clock-icon {
@include color-svg('../images/timer.svg', var(--button-icon-stroke-color));
height: 20px;
width: 20px;
display: inline-block;
}
.module-conversation-header__expiration__setting {
margin-inline-start: 5px;
text-align: center;
}
// Module: Message Detail
.module-message-detail {
max-width: 650px;
margin-inline-start: auto;
margin-inline-end: auto;
padding: 20px;
}
.module-message-detail__message-container {
padding-top: 20px;
padding-bottom: 20px;
&:after {
content: '.';
visibility: hidden;
display: block;
height: 0;
clear: both;
}
}
.module-message-detail__label {
font-weight: 300;
padding-inline-end: 5px;
}
.module-message-detail__delete-button-container {
text-align: center;
margin-top: 10px;
.session-button {
width: 160px;
margin: 1rem auto;
}
}
.module-message-detail__contact-container {
margin: 20px 0 20px 0;
}
.module-message-detail__contact {
margin-bottom: 8px;
display: flex;
flex-direction: row;
align-items: center;
}
.module-message-detail__contact__text {
margin-inline-start: 10px;
flex-grow: 1;
min-width: 0;
}
.module-message-detail__contact__error {
color: var(--danger-color);
font-weight: 300;
}
// Module: Media Gallery
.module-media-gallery {
@ -639,7 +554,8 @@
justify-content: center;
align-items: center;
flex-grow: 1;
font-size: 28px;
margin-top: var(--margins-sm);
font-size: 16px;
}
// Module: Conversation List Item

@ -3,7 +3,7 @@ body.rtl {
textarea,
.module-left-pane,
.module-conversation-list-item,
.group-settings-item,
.right-panel-item,
.contact-selection-list,
.group-member-list__selection,
.contexify_item {

@ -134,13 +134,13 @@ textarea {
.module-message__container {
position: relative;
display: inline-block;
overflow: hidden;
min-width: 30px;
// To limit messages with things forcing them wider, like long attachment names
max-width: 100%;
// To limit messages with things forcing them wider, like long attachment names.
width: 100%;
align-items: center;
border-radius: var(--border-radius-message-box);
}
label {
user-select: none;
}
@ -169,20 +169,6 @@ label {
visibility: hidden;
}
.Toastify__toast {
background: var(--toast-background-color);
color: var(--toast-text-color);
border-left: 4px solid var(--toast-color-strip-color);
.Toastify__close-button {
color: var(--toast-text-color);
}
.Toastify__progress-bar {
background-color: var(--toast-progress-color);
}
}
.session-modal {
animation: fadein var(--default-duration);
z-index: 150;
@ -616,12 +602,6 @@ input {
}
}
.module-message-detail {
.module-message {
pointer-events: none;
}
}
.module-message__text {
white-space: pre-wrap;
}

@ -18,36 +18,7 @@
}
}
.conversation-item__options-pane {
position: absolute;
height: 100%;
right: 0vw;
transition: transform 0.3s ease-in-out;
transform: translateX(100%);
will-change: transform;
width: 25vw;
z-index: 5;
background-color: var(--background-primary-color);
border-left: 1px solid var(--border-color);
&.show {
transform: none;
transition: transform 0.3s ease-in-out;
z-index: 3;
}
}
.conversation-header {
&--items-wrapper {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
width: 100%;
}
.message-selection-overlay {
position: absolute;
display: flex;
@ -99,6 +70,11 @@
flex-direction: column;
position: relative;
outline: none;
height: inherit;
&-left {
flex-grow: 1;
}
.conversation-messages {
display: flex;
@ -112,24 +88,6 @@
background-color: var(--background-secondary-color);
border-top: 1px solid var(--border-color);
}
.conversation-info-panel {
position: absolute;
justify-content: flex-start;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
z-index: 5; // to be sure to hide the borders of images in messages
background-color: inherit;
display: none;
padding: 20px;
&.show {
display: flex;
background: var(--background-primary-color);
}
}
}
.composition-container {

@ -1,111 +0,0 @@
.group-settings {
display: flex;
flex-direction: column;
height: 100%;
width: -webkit-fill-available;
align-items: center;
&-header {
margin-top: var(--margins-lg);
margin-inline-start: var(--margins-sm);
margin-inline-end: var(--margins-sm);
width: -webkit-fill-available;
display: flex;
flex-direction: row;
flex-shrink: 0;
.module-avatar {
margin: auto;
}
}
h2 {
word-break: break-word;
}
.description {
margin: var(--margins-md) 0;
min-height: 4rem;
width: inherit;
color: var(--text-secondary-color);
text-align: center;
display: none;
}
// no double border (top and bottom) between two elements
&-item + &-item {
border-top: none;
}
.module-empty-state {
text-align: center;
}
.module-attachment-section__items {
&-media {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
}
&-documents {
width: 100%;
}
}
.module-media {
&-gallery {
&__tab-container {
padding-top: 1rem;
}
&__tab {
color: var(--text-primary-color);
font-weight: bold;
font-size: 0.9rem;
padding: 0.6rem;
opacity: 0.8;
&--active {
border-bottom: none;
opacity: 1;
&:after {
content: ''; /* This is necessary for the pseudo element to work. */
display: block;
margin: 0 auto;
width: 70%;
padding-top: 0.5rem;
border-bottom: 4px solid var(--primary-color);
}
}
}
&__content {
padding: var(--margins-xs);
margin-bottom: 1vh;
.module-media-grid-item__image,
.module-media-grid-item {
height: calc(
22vw / 4
); //.group-settings is 22vw and we want three rows with some space so divide it by 4
width: calc(
22vw / 4
); //.group-settings is 22vw and we want three rows with some space so divide it by 4
margin: auto;
}
}
}
}
}
.conversation-content {
display: flex;
height: inherit;
&-left {
flex-grow: 1;
}
}

@ -20,7 +20,6 @@
// Build the main view
@import 'index';
@import 'conversation';
// /////////////////// //
// ///// Session ///// //
@ -34,7 +33,6 @@
@import 'session_theme';
@import 'session_left_pane';
@import 'session_group_panel';
@import 'session_slider';
@import 'session_conversation';

@ -2,7 +2,7 @@ import React from 'react';
import styled from 'styled-components';
import { Avatar, AvatarSize, CrownIcon } from './avatar/Avatar';
import { useConversationUsernameOrShorten } from '../hooks/useParamSelector';
import { useNicknameOrProfileNameOrShortenedPubkey } from '../hooks/useParamSelector';
import { SessionRadio } from './basic/SessionRadio';
const AvatarContainer = styled.div`
@ -91,7 +91,7 @@ export const MemberListItem = (props: {
dataTestId,
} = props;
const memberName = useConversationUsernameOrShorten(pubkey);
const memberName = useNicknameOrProfileNameOrShortenedPubkey(pubkey);
return (
<StyledSessionMemberItem

@ -6,7 +6,7 @@ import { SessionIconButton } from './icon';
const StyledNoticeBanner = styled(Flex)`
position: relative;
background-color: var(--primary-color);
color: var(--background-primary-color);
color: var(--black-color);
font-size: var(--font-size-md);
padding: var(--margins-xs) var(--margins-sm);
text-align: center;

@ -1,12 +1,12 @@
import { fromPairs, map } from 'lodash';
import moment from 'moment';
import React from 'react';
import { Provider } from 'react-redux';
import styled from 'styled-components';
import { fromPairs, map } from 'lodash';
import useMount from 'react-use/lib/useMount';
import useUpdate from 'react-use/lib/useUpdate';
import { persistStore } from 'redux-persist';
import { PersistGate } from 'redux-persist/integration/react';
import useUpdate from 'react-use/lib/useUpdate';
import useMount from 'react-use/lib/useMount';
import styled from 'styled-components';
import { LeftPane } from './leftpane/LeftPane';
// moment does not support es-419 correctly (and cause white screen on app start)
@ -26,10 +26,8 @@ import { initialSearchState } from '../state/ducks/search';
import { initialSectionState } from '../state/ducks/section';
import { getEmptyStagedAttachmentsState } from '../state/ducks/stagedAttachments';
import { initialThemeState } from '../state/ducks/theme';
import { TimerOptionsArray } from '../state/ducks/timerOptions';
import { initialUserConfigState } from '../state/ducks/userConfig';
import { StateType } from '../state/reducer';
import { ExpirationTimerOptions } from '../util/expiringMessages';
import { SessionMainPanel } from './SessionMainPanel';
import { SettingsKey } from '../data/settings-key';
@ -64,13 +62,13 @@ function createSessionInboxStore() {
.getConversations()
.map(conversation => conversation.getConversationModelProps());
const timerOptions: TimerOptionsArray = ExpirationTimerOptions.getTimerSecondsWithName();
const initialState: StateType = {
conversations: {
...getEmptyConversationState(),
conversationLookup: makeLookup(conversations, 'id'),
},
user: {
ourDisplayNameInProfile: UserUtils.getOurProfile()?.displayName || '',
ourNumber: UserUtils.getOurPubKeyStrFromCache(),
},
section: initialSectionState,
@ -81,9 +79,6 @@ function createSessionInboxStore() {
onionPaths: initialOnionPathState,
modals: initialModalState,
userConfig: initialUserConfigState,
timerOptions: {
timerOptions,
},
stagedAttachments: getEmptyStagedAttachmentsState(),
call: initialCallState,
sogsRoomInfo: initialSogsRoomInfoState,

@ -1,17 +1,19 @@
import React, { useEffect } from 'react';
import classNames from 'classnames';
import styled from 'styled-components';
import autoBind from 'auto-bind';
import classNames from 'classnames';
import { isString } from 'lodash';
import React, { useEffect } from 'react';
import { toast } from 'react-toastify';
import styled from 'styled-components';
import { SessionButton, SessionButtonColor, SessionButtonType } from './basic/SessionButton';
import { SessionSpinner } from './basic/SessionSpinner';
// import { SessionSpinner } from './basic/SessionSpinner';
import { SessionTheme } from '../themes/SessionTheme';
import { switchPrimaryColorTo } from '../themes/switchPrimaryColor';
import { switchThemeTo } from '../themes/switchTheme';
import { ToastUtils } from '../session/utils';
import { SessionToastContainer } from './SessionToastContainer';
import { SessionWrapperModal } from './SessionWrapperModal';
import { switchPrimaryColorTo } from '../themes/switchPrimaryColor';
import { SessionSpinner } from './basic/SessionSpinner';
import { SessionToast } from './basic/SessionToast';
interface State {
errorCount: number;
@ -34,6 +36,15 @@ const StyledContent = styled.div`
width: 100%;
`;
// We cannot import toastutils from the password window as it is pulling the whole sending
// pipeline(and causing crashes on Session instances with password)
function pushToastError(id: string, title: string, description?: string) {
toast.error(<SessionToast title={title} description={description} />, {
toastId: id,
updateId: id,
});
}
class SessionPasswordPromptInner extends React.PureComponent<unknown, State> {
private inputRef?: any;
@ -112,9 +123,9 @@ class SessionPasswordPromptInner extends React.PureComponent<unknown, State> {
});
if (error && isString(error)) {
ToastUtils.pushToastError('onLogin', error);
pushToastError('onLogin', error);
} else if (error?.message && isString(error.message)) {
ToastUtils.pushToastError('onLogin', error.message);
pushToastError('onLogin', error.message);
}
global.setTimeout(() => {

@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { clearSearch, search, updateSearchTerm } from '../state/ducks/search';
import { getConversationsCount } from '../state/selectors/conversations';
import { getOverlayMode } from '../state/selectors/section';
import { getLeftOverlayMode } from '../state/selectors/section';
import { cleanSearchTerm } from '../util/cleanSearchTerm';
import { SessionIconButton } from './icon';
@ -76,7 +76,7 @@ function updateSearch(dispatch: Dispatch<any>, searchTerm: string) {
export const SessionSearchInput = () => {
const [currentSearchTerm, setCurrentSearchTerm] = useState('');
const dispatch = useDispatch();
const isGroupCreationSearch = useSelector(getOverlayMode) === 'closed-group';
const isGroupCreationSearch = useSelector(getLeftOverlayMode) === 'closed-group';
const convoCount = useSelector(getConversationsCount);
// just after onboard we only have a conversation with ourself

@ -2,16 +2,42 @@ import React from 'react';
import { Slide, ToastContainer, ToastContainerProps } from 'react-toastify';
import styled from 'styled-components';
// NOTE: https://styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity
const StyledToastContainer = styled(ToastContainer)`
&&&.Toastify__toast-container {
}
.Toastify__toast {
background: var(--toast-background-color);
color: var(--toast-text-color);
border-left: 4px solid var(--toast-color-strip-color);
}
.Toastify__toast--error {
}
.Toastify__toast--warning {
}
.Toastify__toast--success {
}
.Toastify__toast-body {
line-height: 1.4;
}
.Toastify__progress-bar {
background-color: var(--toast-progress-color);
}
.Toastify__close-button {
color: var(--toast-text-color);
}
`;
const WrappedToastContainer = ({
className,
...rest
}: ToastContainerProps & { className?: string }) => (
<div className={className}>
<ToastContainer {...rest} />
<StyledToastContainer {...rest} />
</div>
);
const SessionToastContainerPrivate = () => {
export const SessionToastContainer = () => {
return (
<WrappedToastContainer
position="bottom-right"
@ -28,22 +54,3 @@ const SessionToastContainerPrivate = () => {
/>
);
};
export const SessionToastContainer = styled(SessionToastContainerPrivate).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
}
.Toastify__toast--error {
}
.Toastify__toast--warning {
}
.Toastify__toast--success {
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
`;

@ -1,8 +1,8 @@
import classNames from 'classnames';
import { isEqual } from 'lodash';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { isEqual } from 'lodash';
import { useDisableDrag } from '../../hooks/useDisableDrag';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
@ -48,15 +48,14 @@ const CrownWrapper = styled.div`
align-items: center;
justify-content: center;
position: absolute;
bottom: 0%;
right: 12%;
height: 20px;
width: 20px;
transform: translate(25%, 25%);
bottom: 11%;
right: 11%;
height: 18px;
width: 18px;
transform: translate(20%, 20%); // getting over 23% creates a glitch
color: #f7c347;
background: var(--background-primary-color);
border-radius: 50%;
box-shadow: var(--drop-shadow);
`;
export const CrownIcon = () => {

@ -1,16 +1,7 @@
import React, { ChangeEvent } from 'react';
import styled from 'styled-components';
import styled, { CSSProperties } from 'styled-components';
import { Flex } from './Flex';
type Props = {
label: string;
value: string;
active: boolean;
inputName?: string;
beforeMargins?: string;
onClick?: (value: string) => void;
};
const StyledInput = styled.input<{
filledSize: number;
outlineOffset: number;
@ -18,29 +9,35 @@ const StyledInput = styled.input<{
}>`
opacity: 0;
position: absolute;
cursor: pointer;
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
width: ${props => props.filledSize + props.outlineOffset}px;
height: ${props => props.filledSize + props.outlineOffset}px;
:checked + label:before {
background: ${props => (props.selectedColor ? props.selectedColor : 'var(--primary-color)')};
background: ${props =>
props.disabled
? 'var(--disabled-color)'
: props.selectedColor
? props.selectedColor
: 'var(--primary-color)'};
}
`;
// NOTE (Will): We don't use a transition because it's too slow and creates flickering when changing buttons.
const StyledLabel = styled.label<{
disabled: boolean;
filledSize: number;
outlineOffset: number;
beforeMargins?: string;
}>`
cursor: pointer;
color: var(--text-primary-color);
color: ${props => (props.disabled ? 'var(--disabled-color)' : 'var(--text-primary-color)')};
:before {
content: '';
display: inline-block;
border-radius: 100%;
transition: var(--default-duration);
padding: ${props => props.filledSize}px;
border: none;
outline: 1px solid currentColor; /* CSS variables don't work here */
@ -49,22 +46,49 @@ const StyledLabel = styled.label<{
}
`;
export const SessionRadio = (props: Props) => {
const { label, inputName, value, active, onClick, beforeMargins } = props;
type SessionRadioProps = {
label: string;
value: string;
active: boolean;
inputName?: string;
beforeMargins?: string;
onClick?: (value: string) => void;
disabled?: boolean;
radioPosition?: 'left' | 'right';
style?: CSSProperties;
};
function clickHandler(e: ChangeEvent<any>) {
if (onClick) {
export const SessionRadio = (props: SessionRadioProps) => {
const {
label,
inputName,
value,
active,
onClick,
beforeMargins,
disabled = false,
radioPosition = 'left',
style,
} = props;
const clickHandler = (e: ChangeEvent<any>) => {
if (!disabled && onClick) {
// let something else catch the event if our click handler is not set
e.stopPropagation();
onClick(value);
}
}
};
const filledSize = 15 / 2;
const outlineOffset = 2;
return (
<Flex container={true} padding="0 0 0 var(--margins-lg)">
<Flex
container={true}
flexDirection={radioPosition === 'left' ? 'row' : 'row-reverse'}
justifyContent={radioPosition === 'left' ? 'flex-start' : 'flex-end'}
style={style}
>
<StyledInput
type="radio"
name={inputName || ''}
@ -74,7 +98,8 @@ export const SessionRadio = (props: Props) => {
onChange={clickHandler}
filledSize={filledSize * 2}
outlineOffset={outlineOffset}
data-testid={`input-${value}`}
disabled={disabled}
data-testid={`input-${value.replaceAll(' ', '-')}`} // data-testid cannot have spaces
/>
<StyledLabel
role="button"
@ -83,6 +108,7 @@ export const SessionRadio = (props: Props) => {
outlineOffset={outlineOffset}
beforeMargins={beforeMargins}
aria-label={label}
disabled={disabled}
data-testid={`label-${value}`}
>
{label}
@ -92,7 +118,7 @@ export const SessionRadio = (props: Props) => {
};
const StyledInputOutlineSelected = styled(StyledInput)`
color: var(--text-primary-color);
color: ${props => (props.disabled ? 'var(--disabled-color)' : 'var(--text-primary-color)')};
label:before,
label:before {
outline: none;
@ -103,7 +129,12 @@ const StyledInputOutlineSelected = styled(StyledInput)`
`;
const StyledLabelOutlineSelected = styled(StyledLabel)<{ selectedColor: string }>`
:before {
background: ${props => (props.selectedColor ? props.selectedColor : 'var(--primary-color)')};
background: ${props =>
props.disabled
? 'var(--disabled-color)'
: props.selectedColor
? props.selectedColor
: 'var(--primary-color)'};
outline: 1px solid transparent; /* CSS variables don't work here */
}
`;
@ -118,8 +149,9 @@ export const SessionRadioPrimaryColors = (props: {
onClick: (value: string) => void;
ariaLabel: string;
color: string; // by default, we use the theme accent color but for the settings screen we need to be able to force it
disabled?: boolean;
}) => {
const { inputName, value, active, onClick, color, ariaLabel } = props;
const { inputName, value, active, onClick, color, ariaLabel, disabled = false } = props;
function clickHandler(e: ChangeEvent<any>) {
e.stopPropagation();
@ -142,6 +174,7 @@ export const SessionRadioPrimaryColors = (props: {
outlineOffset={outlineOffset}
selectedColor={color}
aria-label={ariaLabel}
disabled={disabled}
/>
<StyledLabelOutlineSelected
@ -150,6 +183,7 @@ export const SessionRadioPrimaryColors = (props: {
selectedColor={color}
filledSize={filledSize}
outlineOffset={outlineOffset}
disabled={disabled}
>
{''}
</StyledLabelOutlineSelected>

@ -4,11 +4,14 @@ import styled, { CSSProperties } from 'styled-components';
import { SessionRadio } from './SessionRadio';
export type SessionRadioItems = Array<{ value: string; label: string }>;
interface Props {
initialItem: string;
items: Array<{ value: string; label: string }>;
items: SessionRadioItems;
group: string;
onClick: (selectedValue: string) => void;
radioPosition?: 'left' | 'right';
style?: CSSProperties;
}
@ -29,7 +32,7 @@ const StyledFieldSet = styled.fieldset`
`;
export const SessionRadioGroup = (props: Props) => {
const { items, group, initialItem, style } = props;
const { items, group, initialItem, radioPosition, style } = props;
const [activeItem, setActiveItem] = useState('');
useMount(() => {
@ -53,6 +56,7 @@ export const SessionRadioGroup = (props: Props) => {
props.onClick(value);
}}
beforeMargins={'0 var(--margins-sm) 0 0 '}
radioPosition={radioPosition}
/>
);
})}

@ -26,7 +26,7 @@ type Props = {
const TitleDiv = styled.div`
font-size: var(--font-size-md);
line-height: var(--font-size-md);
line-height: 1.5;
font-family: var(--font-default);
color: var(--text-primary-color);
text-overflow: ellipsis;
@ -43,7 +43,7 @@ const DescriptionDiv = styled.div`
const IconDiv = styled.div`
flex-shrink: 0;
padding-inline-end: var(--margins-xs);
margin: 0 var(--margins-xs);
margin: 0 var(--margins-sm) 0 var(--margins-xs);
`;
export const SessionToast = (props: Props) => {

@ -10,7 +10,7 @@ type TextProps = {
ellipsisOverflow?: boolean;
};
const StyledDefaultText = styled.div<TextProps>`
const StyledDefaultText = styled.div<Omit<TextProps, 'text'>>`
transition: var(--default-duration);
max-width: ${props => (props.maxWidth ? props.maxWidth : '')};
padding: ${props => (props.padding ? props.padding : '')};
@ -26,14 +26,22 @@ export const Text = (props: TextProps) => {
return <StyledDefaultText {...props}>{props.text}</StyledDefaultText>;
};
export const TextWithChildren = (
props: Omit<TextProps, 'text'> & { children: React.ReactNode }
) => {
return <StyledDefaultText {...props}>{props.children}</StyledDefaultText>;
};
type SpacerProps = {
size: 'lg' | 'md' | 'sm' | 'xs';
size: 'xl' | 'lg' | 'md' | 'sm' | 'xs';
style?: CSSProperties;
};
const SpacerStyled = styled.div<SpacerProps>`
height: ${props =>
props.size === 'lg'
props.size === 'xl'
? 'var(--margins-xl)'
: props.size === 'lg'
? 'var(--margins-lg)'
: props.size === 'md'
? 'var(--margins-md)'
@ -42,7 +50,9 @@ const SpacerStyled = styled.div<SpacerProps>`
: 'var(--margins-xs)'};
width: ${props =>
props.size === 'lg'
props.size === 'xl'
? 'var(--margins-xl)'
: props.size === 'lg'
? 'var(--margins-lg)'
: props.size === 'md'
? 'var(--margins-md)'
@ -55,6 +65,10 @@ const Spacer = (props: SpacerProps) => {
return <SpacerStyled {...props} />;
};
export const SpacerXL = (props: { style?: CSSProperties }) => {
return <Spacer size="xl" style={props.style} />;
};
export const SpacerLG = (props: { style?: CSSProperties }) => {
return <Spacer size="lg" style={props.style} />;
};

@ -1,8 +1,8 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { resetOverlayMode, setOverlayMode } from '../../state/ducks/section';
import { getOverlayMode } from '../../state/selectors/section';
import { resetLeftOverlayMode, setLeftOverlayMode } from '../../state/ducks/section';
import { getLeftOverlayMode } from '../../state/selectors/section';
import { SessionIcon } from '../icon';
const StyledMenuButton = styled.button`
@ -31,13 +31,13 @@ const StyledMenuButton = styled.button`
* It has two state: selected or not and so we use an checkbox input to keep the state in sync.
*/
export const MenuButton = () => {
const overlayMode = useSelector(getOverlayMode);
const leftOverlayMode = useSelector(getLeftOverlayMode);
const dispatch = useDispatch();
const isToggled = Boolean(overlayMode);
const isToggled = Boolean(leftOverlayMode);
const onClickFn = () =>
dispatch(isToggled ? resetOverlayMode() : setOverlayMode('choose-action'));
dispatch(isToggled ? resetLeftOverlayMode() : setLeftOverlayMode('choose-action'));
return (
<StyledMenuButton data-testid="new-conversation-button" onClick={onClickFn}>

@ -0,0 +1,133 @@
import React, { ReactNode } from 'react';
import styled, { CSSProperties } from 'styled-components';
import { Flex } from '../basic/Flex';
// NOTE Used for descendant components
export const StyledContent = styled.div<{ disabled: boolean }>`
display: flex;
align-items: center;
width: 100%;
color: ${props => (props.disabled ? 'var(--disabled-color)' : 'inherit')};
`;
export const StyledText = styled.span<{ color?: string }>`
font-size: var(--font-size-md);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: start;
${props => props.color && `color: ${props.color};`}
`;
export const PanelLabel = styled.p`
color: var(--text-secondary-color);
width: 100%;
margin: 0;
padding-left: calc(var(--margins-lg) * 2 + var(--margins-sm));
padding-bottom: var(--margins-sm);
`;
const StyledRoundedPanelButtonGroup = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
background: var(--right-panel-item-background-color);
border-radius: 16px;
padding: 0 var(--margins-lg) var(--margins-xs);
margin: 0 var(--margins-lg);
width: -webkit-fill-available;
`;
const PanelButtonContainer = styled.div`
overflow: auto;
min-height: 65px;
max-height: 100%;
`;
type PanelButtonGroupProps = {
children: ReactNode;
style?: CSSProperties;
};
export const PanelButtonGroup = (props: PanelButtonGroupProps) => {
const { children, style } = props;
return (
<StyledRoundedPanelButtonGroup style={style}>
<PanelButtonContainer>{children}</PanelButtonContainer>
</StyledRoundedPanelButtonGroup>
);
};
const StyledPanelButton = styled.button<{
disabled: boolean;
}>`
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
flex-grow: 1;
font-family: var(--font-default);
height: 65px;
width: 100%;
transition: var(--default-duration);
color: ${props => (props.disabled ? 'var(--disabled-color)' : 'inherit')};
:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
`;
export type PanelButtonProps = {
// https://styled-components.com/docs/basics#styling-any-component
className?: string;
disabled?: boolean;
children: ReactNode;
onClick: (...args: Array<any>) => void;
dataTestId: string;
style?: CSSProperties;
};
export const PanelButton = (props: PanelButtonProps) => {
const { className, disabled = false, children, onClick, dataTestId, style } = props;
return (
<StyledPanelButton
className={className}
disabled={disabled}
onClick={onClick}
style={style}
data-testid={dataTestId}
>
{children}
</StyledPanelButton>
);
};
const StyledSubtitle = styled.p<{ color?: string }>`
font-size: var(--font-size-xs);
line-height: 1.1;
margin-top: 0;
margin-bottom: 0;
text-align: start;
${props => props.color && `color: ${props.color};`}
`;
export const PanelButtonText = (props: { text: string; subtitle?: string; color?: string }) => {
return (
<Flex
container={true}
width={'100%'}
flexDirection={'column'}
alignItems={'flex-start'}
margin="0 var(--margins-lg) 0 0"
minWidth="0"
>
<StyledText color={props.color}>{props.text}</StyledText>
{!!props.subtitle && <StyledSubtitle color={props.color}>{props.subtitle}</StyledSubtitle>}
</Flex>
);
};

@ -0,0 +1,32 @@
import React from 'react';
import styled from 'styled-components';
import { SessionIcon, SessionIconType } from '../icon';
import { PanelButton, PanelButtonProps, PanelButtonText, StyledContent } from './PanelButton';
interface PanelIconButton extends Omit<PanelButtonProps, 'children'> {
iconType: SessionIconType;
text: string;
subtitle?: string;
color?: string;
}
const IconContainer = styled.div`
flex-shrink: 0;
margin: 0 var(--margins-lg) 0 var(--margins-sm);
padding: 0;
`;
export const PanelIconButton = (props: PanelIconButton) => {
const { iconType, text, subtitle, color, disabled = false, onClick, dataTestId } = props;
return (
<PanelButton disabled={disabled} onClick={onClick} dataTestId={dataTestId}>
<StyledContent disabled={disabled}>
<IconContainer>
<SessionIcon iconType={iconType} iconColor={color} iconSize="large" />
</IconContainer>
<PanelButtonText text={text} subtitle={subtitle} color={color} />
</StyledContent>
</PanelButton>
);
};

@ -0,0 +1,60 @@
import React from 'react';
import styled from 'styled-components';
import { SessionRadio } from '../basic/SessionRadio';
import { PanelButton, PanelButtonProps, PanelButtonText, StyledContent } from './PanelButton';
const StyledPanelButton = styled(PanelButton)`
padding-top: var(--margins-lg);
padding-bottom: var(--margins-lg);
text-align: start;
`;
const StyledCheckContainer = styled.div`
display: flex;
align-items: center;
`;
interface PanelRadioButtonProps extends Omit<PanelButtonProps, 'children' | 'onClick'> {
value: any;
text: string;
subtitle?: string;
isSelected: boolean;
onSelect?: (...args: Array<any>) => void;
onUnselect?: (...args: Array<any>) => void;
}
export const PanelRadioButton = (props: PanelRadioButtonProps) => {
const {
value,
text,
subtitle,
isSelected,
onSelect,
onUnselect,
disabled = false,
dataTestId,
} = props;
return (
<StyledPanelButton
disabled={disabled}
onClick={() => {
return isSelected ? onUnselect?.('bye') : onSelect?.('hi');
}}
dataTestId={dataTestId}
>
<StyledContent disabled={disabled}>
<PanelButtonText text={text} subtitle={subtitle} />
<StyledCheckContainer>
<SessionRadio
active={isSelected}
value={value}
inputName={value}
label=""
disabled={disabled}
/>
</StyledCheckContainer>
</StyledContent>
</StyledPanelButton>
);
};

@ -0,0 +1,5 @@
import { MenuButton } from './MenuButton';
import { PanelButton, PanelButtonGroup } from './PanelButton';
import { PanelIconButton } from './PanelIconButton';
export { MenuButton, PanelButton, PanelButtonGroup, PanelIconButton };

@ -3,7 +3,10 @@ import classNames from 'classnames';
import { CSSProperties } from 'styled-components';
import { Emojify } from './Emojify';
import { useConversationUsernameOrShorten, useIsPrivate } from '../../hooks/useParamSelector';
import {
useNicknameOrProfileNameOrShortenedPubkey,
useIsPrivate,
} from '../../hooks/useParamSelector';
type Props = {
pubkey: string;
@ -19,7 +22,7 @@ export const ContactName = (props: Props) => {
const { pubkey, name, profileName, module, boldProfileName, compact, shouldShowPubkey } = props;
const prefix = module || 'module-contact-name';
const convoName = useConversationUsernameOrShorten(pubkey);
const convoName = useNicknameOrProfileNameOrShortenedPubkey(pubkey);
const isPrivate = useIsPrivate(pubkey);
const shouldShowProfile = Boolean(convoName || profileName || name);
const styles = (boldProfileName

@ -1,416 +0,0 @@
import React from 'react';
import { contextMenu } from 'react-contexify';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { ConversationNotificationSettingType } from '../../models/conversationAttributes';
import { Avatar, AvatarSize } from '../avatar/Avatar';
import {
getSelectedMessageIds,
isMessageDetailView,
isMessageSelectionMode,
isRightPanelShowing,
} from '../../state/selectors/conversations';
import {
useConversationUsername,
useExpireTimer,
useIsKickedFromGroup,
} from '../../hooks/useParamSelector';
import { callRecipient } from '../../interactions/conversationInteractions';
import {
deleteMessagesById,
deleteMessagesByIdForEveryone,
} from '../../interactions/conversations/unsendingInteractions';
import {
closeMessageDetailsView,
closeRightPanel,
openRightPanel,
resetSelectedMessageIds,
} from '../../state/ducks/conversations';
import { getHasIncomingCall, getHasOngoingCall } from '../../state/selectors/call';
import {
useSelectedConversationKey,
useSelectedIsActive,
useSelectedIsBlocked,
useSelectedIsGroup,
useSelectedIsKickedFromGroup,
useSelectedIsPrivate,
useSelectedIsPrivateFriend,
useSelectedIsPublic,
useSelectedMembers,
useSelectedNotificationSetting,
useSelectedSubscriberCount,
useSelectedisNoteToSelf,
} from '../../state/selectors/selectedConversation';
import { ExpirationTimerOptions } from '../../util/expiringMessages';
import { Flex } from '../basic/Flex';
import {
SessionButton,
SessionButtonColor,
SessionButtonShape,
SessionButtonType,
} from '../basic/SessionButton';
import { SessionIconButton } from '../icon';
import { ConversationHeaderMenu } from '../menu/ConversationHeaderMenu';
export interface TimerOption {
name: string;
value: number;
}
const SelectionOverlay = () => {
const selectedMessageIds = useSelector(getSelectedMessageIds);
const selectedConversationKey = useSelectedConversationKey();
const isPublic = useSelectedIsPublic();
const dispatch = useDispatch();
const { i18n } = window;
function onCloseOverlay() {
dispatch(resetSelectedMessageIds());
}
function onDeleteSelectedMessages() {
if (selectedConversationKey) {
void deleteMessagesById(selectedMessageIds, selectedConversationKey);
}
}
function onDeleteSelectedMessagesForEveryone() {
if (selectedConversationKey) {
void deleteMessagesByIdForEveryone(selectedMessageIds, selectedConversationKey);
}
}
const isOnlyServerDeletable = isPublic;
const deleteMessageButtonText = i18n('delete');
const deleteForEveryoneMessageButtonText = i18n('deleteForEveryone');
return (
<div className="message-selection-overlay">
<div className="close-button">
<SessionIconButton iconType="exit" iconSize="medium" onClick={onCloseOverlay} />
</div>
<div className="button-group">
{!isOnlyServerDeletable && (
<SessionButton
buttonColor={SessionButtonColor.Danger}
buttonShape={SessionButtonShape.Square}
buttonType={SessionButtonType.Solid}
text={deleteMessageButtonText}
onClick={onDeleteSelectedMessages}
/>
)}
<SessionButton
buttonColor={SessionButtonColor.Danger}
buttonShape={SessionButtonShape.Square}
buttonType={SessionButtonType.Solid}
text={deleteForEveryoneMessageButtonText}
onClick={onDeleteSelectedMessagesForEveryone}
/>
</div>
</div>
);
};
const TripleDotContainer = styled.div`
user-select: none;
flex-grow: 0;
flex-shrink: 0;
`;
const TripleDotsMenu = (props: { triggerId: string; showBackButton: boolean }) => {
const { showBackButton } = props;
const isPrivateFriend = useSelectedIsPrivateFriend();
const isPrivate = useSelectedIsPrivate();
if (showBackButton) {
return null;
}
if (isPrivate && !isPrivateFriend) {
return null;
}
return (
<TripleDotContainer
role="button"
onClick={(e: any) => {
contextMenu.show({
id: props.triggerId,
event: e,
});
}}
data-testid="three-dots-conversation-options"
>
<SessionIconButton iconType="ellipses" iconSize="medium" />
</TripleDotContainer>
);
};
const ExpirationLength = (props: { expirationSettingName?: string }) => {
const { expirationSettingName } = props;
if (!expirationSettingName) {
return null;
}
return (
<div className="module-conversation-header__expiration">
<div className="module-conversation-header__expiration__clock-icon" />
<div
className="module-conversation-header__expiration__setting"
data-testid="disappearing-messages-indicator"
>
{expirationSettingName}
</div>
</div>
);
};
const AvatarHeader = (props: {
pubkey: string;
showBackButton: boolean;
onAvatarClick?: (pubkey: string) => void;
}) => {
const { pubkey, onAvatarClick, showBackButton } = props;
return (
<span className="module-conversation-header__avatar">
<Avatar
size={AvatarSize.S}
onAvatarClick={() => {
// do not allow right panel to appear if another button is shown on the SessionConversation
if (onAvatarClick && !showBackButton) {
onAvatarClick(pubkey);
}
}}
pubkey={pubkey}
dataTestId="conversation-options-avatar"
/>
</span>
);
};
const BackButton = (props: { onGoBack: () => void; showBackButton: boolean }) => {
const { onGoBack, showBackButton } = props;
if (!showBackButton) {
return null;
}
return (
<SessionIconButton
iconType="chevron"
iconSize="large"
iconRotation={90}
onClick={onGoBack}
dataTestId="back-button-message-details"
/>
);
};
const CallButton = () => {
const isPrivate = useSelectedIsPrivate();
const isBlocked = useSelectedIsBlocked();
const isActive = useSelectedIsActive();
const isMe = useSelectedisNoteToSelf();
const selectedConvoKey = useSelectedConversationKey();
const hasIncomingCall = useSelector(getHasIncomingCall);
const hasOngoingCall = useSelector(getHasOngoingCall);
const canCall = !(hasIncomingCall || hasOngoingCall);
const isPrivateFriend = useSelectedIsPrivateFriend();
if (
!isPrivate ||
isMe ||
!selectedConvoKey ||
isBlocked ||
!isActive ||
!isPrivateFriend // call requires us to be friends
) {
return null;
}
return (
<SessionIconButton
iconType="phone"
iconSize="large"
iconPadding="2px"
margin="0 10px 0 0"
onClick={() => {
void callRecipient(selectedConvoKey, canCall);
}}
dataTestId="call-button"
/>
);
};
export const StyledSubtitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
span:last-child {
margin-bottom: 0;
}
`;
export type ConversationHeaderTitleProps = {
conversationKey: string;
isMe: boolean;
isGroup: boolean;
isPublic: boolean;
members: Array<any>;
isKickedFromGroup: boolean;
currentNotificationSetting?: ConversationNotificationSettingType;
};
/**
* The subtitle beneath a conversation title when looking at a conversation screen.
* @param props props for subtitle. Text to be displayed
* @returns JSX Element of the subtitle of conversation header
*/
export const ConversationHeaderSubtitle = (props: { text?: string | null }): JSX.Element | null => {
const { text } = props;
if (!text) {
return null;
}
return <span className="module-conversation-header__title-text">{text}</span>;
};
const ConversationHeaderTitle = () => {
const dispatch = useDispatch();
const notificationSetting = useSelectedNotificationSetting();
const isRightPanelOn = useSelector(isRightPanelShowing);
const subscriberCount = useSelectedSubscriberCount();
const selectedConvoKey = useSelectedConversationKey();
const convoName = useConversationUsername(selectedConvoKey);
const isPublic = useSelectedIsPublic();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const isMe = useSelectedisNoteToSelf();
const isGroup = useSelectedIsGroup();
const members = useSelectedMembers();
if (!selectedConvoKey) {
return null;
}
const { i18n } = window;
if (isMe) {
return <div className="module-conversation-header__title">{i18n('noteToSelf')}</div>;
}
let memberCount = 0;
if (isGroup) {
if (isPublic) {
memberCount = subscriberCount || 0;
} else {
memberCount = members.length;
}
}
let memberCountText = '';
if (isGroup && memberCount > 0 && !isKickedFromGroup) {
const count = String(memberCount);
memberCountText = isPublic ? i18n('activeMembers', [count]) : i18n('members', [count]);
}
const notificationSubtitle = notificationSetting
? window.i18n('notificationSubtitle', [notificationSetting])
: null;
const fullTextSubtitle = memberCountText
? `${memberCountText}${notificationSubtitle}`
: `${notificationSubtitle}`;
return (
<div
className="module-conversation-header__title"
onClick={() => {
if (isRightPanelOn) {
dispatch(closeRightPanel());
} else {
dispatch(openRightPanel());
}
}}
role="button"
>
<span className="module-contact-name__profile-name" data-testid="header-conversation-name">
{convoName}
</span>
<StyledSubtitleContainer>
<ConversationHeaderSubtitle text={fullTextSubtitle} />
</StyledSubtitleContainer>
</div>
);
};
export const ConversationHeaderWithDetails = () => {
const isSelectionMode = useSelector(isMessageSelectionMode);
const isMessageDetailOpened = useSelector(isMessageDetailView);
const selectedConvoKey = useSelectedConversationKey();
const dispatch = useDispatch();
const isKickedFromGroup = useIsKickedFromGroup(selectedConvoKey);
const expireTimerSetting = useExpireTimer(selectedConvoKey);
if (!selectedConvoKey) {
return null;
}
const expirationSettingName = expireTimerSetting
? ExpirationTimerOptions.getName(expireTimerSetting || 0)
: undefined;
const triggerId = 'conversation-header';
return (
<div className="module-conversation-header">
<div className="conversation-header--items-wrapper">
<BackButton
onGoBack={() => {
dispatch(closeMessageDetailsView());
}}
showBackButton={isMessageDetailOpened}
/>
<TripleDotsMenu triggerId={triggerId} showBackButton={isMessageDetailOpened} />
<div className="module-conversation-header__title-container">
<div className="module-conversation-header__title-flex">
<ConversationHeaderTitle />
</div>
</div>
{!isSelectionMode && (
<Flex
container={true}
flexDirection="row"
alignItems="center"
flexGrow={0}
flexShrink={0}
>
{!isKickedFromGroup && (
<ExpirationLength expirationSettingName={expirationSettingName} />
)}
<CallButton />
<AvatarHeader
onAvatarClick={() => {
dispatch(openRightPanel());
}}
pubkey={selectedConvoKey}
showBackButton={isMessageDetailOpened}
/>
</Flex>
)}
<ConversationHeaderMenu triggerId={triggerId} />
</div>
{isSelectionMode && <SelectionOverlay />}
</div>
);
};

@ -1,42 +1,28 @@
import React, { useCallback, useState } from 'react';
import useInterval from 'react-use/lib/useInterval';
import styled from 'styled-components';
import styled, { CSSProperties } from 'styled-components';
import { getTimerBucketIcon } from '../../util/timer';
import { SessionIcon } from '../icon/SessionIcon';
type Props = {
expirationLength: number;
expirationTimestamp: number | null;
isCorrectSide: boolean;
};
const ExpireTimerCount = styled.div<{
color: string;
}>`
margin-inline-start: 6px;
font-size: var(--font-size-xs);
line-height: 16px;
letter-spacing: 0.3px;
text-transform: uppercase;
user-select: none;
color: ${props => props.color};
flex-shrink: 0;
`;
const ExpireTimerBucket = styled.div`
margin-inline-start: 6px;
font-size: var(--font-size-xs);
line-height: 16px;
letter-spacing: 0.3px;
text-transform: uppercase;
user-select: none;
color: var(--text-primary-color);
color: var(--text-secondary-color);
align-self: center;
`;
type Props = {
expirationDurationMs?: number;
expirationTimestamp?: number | null;
style?: CSSProperties;
};
export const ExpireTimer = (props: Props) => {
const { expirationLength, expirationTimestamp, isCorrectSide } = props;
const { expirationDurationMs, expirationTimestamp, style } = props;
const initialTimeLeft = Math.max(Math.round(((expirationTimestamp || 0) - Date.now()) / 1000), 0);
const [timeLeft, setTimeLeft] = useState(initialTimeLeft);
@ -53,20 +39,15 @@ export const ExpireTimer = (props: Props) => {
const updateFrequency = 500;
useInterval(update, updateFrequency);
if (!(isCorrectSide && expirationLength && expirationTimestamp)) {
if (!(expirationDurationMs && expirationTimestamp)) {
return null;
}
const expireTimerColor = 'var(--primary-text-color)';
if (timeLeft <= 60) {
return <ExpireTimerCount color={expireTimerColor}>{timeLeft}</ExpireTimerCount>;
}
const bucket = getTimerBucketIcon(expirationTimestamp, expirationLength);
const bucket = getTimerBucketIcon(expirationTimestamp, expirationDurationMs);
return (
<ExpireTimerBucket>
<SessionIcon iconType={bucket} iconSize="tiny" iconColor={expireTimerColor} />
<ExpireTimerBucket style={style}>
<SessionIcon iconType={bucket} iconSize="tiny" iconColor={'var(--secondary-text-color)'} />
</ExpireTimerBucket>
);
};

@ -5,6 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
import { setNextMessageToPlayId } from '../../state/ducks/conversations';
import { useMessageSelected } from '../../state/selectors';
import {
getNextMessageToPlayId,
getSortedMessagesOfSelectedConversation,
@ -25,7 +26,7 @@ const StyledSpeedButton = styled.div`
}
`;
export const StyledH5AudioPlayer = styled(H5AudioPlayer)`
export const StyledH5AudioPlayer = styled(H5AudioPlayer)<{ dropShadow?: boolean }>`
&.rhap_container {
min-width: 220px;
padding: 0px;
@ -145,6 +146,8 @@ export const StyledH5AudioPlayer = styled(H5AudioPlayer)`
height: 20px;
margin-right: 0px;
}
${props => props.dropShadow && 'box-shadow: var(--drop-shadow);'}
`;
export const AudioPlayerWithEncryptedFile = (props: {
@ -162,6 +165,7 @@ export const AudioPlayerWithEncryptedFile = (props: {
const messageProps = useSelector(getSortedMessagesOfSelectedConversation);
const nextMessageToPlayId = useSelector(getNextMessageToPlayId);
const multiSelectMode = useSelector(isMessageSelectionMode);
const selected = useMessageSelected(messageId);
const dataTestId = `audio-${messageId}`;
@ -263,6 +267,7 @@ export const AudioPlayerWithEncryptedFile = (props: {
play: <SessionIcon iconType="play" iconSize="small" />,
pause: <SessionIcon iconType="pause" iconSize="small" />,
}}
dropShadow={selected}
/>
);
};

@ -1,19 +1,20 @@
import React, { useCallback } from 'react';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { Spinner } from '../basic/Spinner';
import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
import { isNumber } from 'lodash';
import { useDisableDrag } from '../../hooks/useDisableDrag';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment';
import { Spinner } from '../basic/Spinner';
type Props = {
alt: string;
attachment: AttachmentTypeWithPath | AttachmentType;
url: string | undefined; // url is undefined if the message is not visible yet
height?: number;
width?: number;
height?: number | string;
width?: number | string;
overlayText?: string;
@ -23,6 +24,7 @@ type Props = {
playIconOverlay?: boolean;
softCorners: boolean;
forceSquare?: boolean;
dropShadow?: boolean;
attachmentIndex?: number;
onClick?: (attachment: AttachmentTypeWithPath | AttachmentType) => void;
@ -46,7 +48,7 @@ export const Image = (props: Props) => {
attachment,
closeButton,
darkOverlay,
height,
height: _height,
onClick,
onClickClose,
onError,
@ -54,9 +56,10 @@ export const Image = (props: Props) => {
playIconOverlay,
softCorners,
forceSquare,
dropShadow,
attachmentIndex,
url,
width,
width: _width,
} = props;
const onErrorUrlFilterering = useCallback(() => {
@ -78,6 +81,9 @@ export const Image = (props: Props) => {
// data will be url if loading is finished and '' if not
const srcData = !loading ? urlToLoad : '';
const width = isNumber(_width) ? `${_width}px` : _width;
const height = isNumber(_height) ? `${_height}px` : _height;
return (
<div
role={role}
@ -93,10 +99,11 @@ export const Image = (props: Props) => {
softCorners ? 'module-image--soft-corners' : null
)}
style={{
maxHeight: `${height}px`,
maxWidth: `${width}px`,
minHeight: `${height}px`,
minWidth: `${width}px`,
maxHeight: height,
maxWidth: width,
minHeight: height,
minWidth: width,
boxShadow: dropShadow ? 'var(--drop-shadow)' : undefined,
}}
data-attachmentindex={attachmentIndex}
>
@ -104,11 +111,11 @@ export const Image = (props: Props) => {
<div
className="module-image__loading-placeholder"
style={{
maxHeight: `${height}px`,
maxWidth: `${width}px`,
width: `${width}px`,
height: `${height}px`,
lineHeight: `${height}px`,
maxHeight: height,
maxWidth: width,
width,
height,
lineHeight: height,
textAlign: 'center',
}}
>
@ -123,12 +130,12 @@ export const Image = (props: Props) => {
)}
alt={alt}
style={{
maxHeight: `${height}px`,
maxWidth: `${width}px`,
minHeight: `${height}px`,
minWidth: `${width}px`,
width: forceSquare ? `${width}px` : '',
height: forceSquare ? `${height}px` : '',
maxHeight: height,
maxWidth: width,
minHeight: height,
minWidth: width,
width: forceSquare ? width : '',
height: forceSquare ? height : '',
}}
src={srcData}
onDragStart={disableDrag}
@ -165,7 +172,7 @@ export const Image = (props: Props) => {
</div>
) : null}
{overlayText ? (
<div className="module-image__text-container" style={{ lineHeight: `${height}px` }}>
<div className="module-image__text-container" style={{ lineHeight: height }}>
{overlayText}
</div>
) : null}

@ -10,14 +10,16 @@ import {
isVideoAttachment,
} from '../../types/Attachment';
import { useMessageSelected } from '../../state/selectors';
import { THUMBNAIL_SIDE } from '../../types/attachments/VisualAttachment';
import { Image } from './Image';
import { IsMessageVisibleContext } from './message/message-content/MessageContent';
import { THUMBNAIL_SIDE } from '../../types/attachments/VisualAttachment';
type Props = {
attachments: Array<AttachmentTypeWithPath>;
onError: () => void;
onClickAttachment?: (attachment: AttachmentTypeWithPath | AttachmentType) => void;
messageId?: string;
};
const StyledImageGrid = styled.div<{ flexDirection: 'row' | 'column' }>`
@ -28,7 +30,12 @@ const StyledImageGrid = styled.div<{ flexDirection: 'row' | 'column' }>`
`;
const Row = (
props: Props & { renderedSize: number; startIndex: number; totalAttachmentsCount: number }
props: Props & {
renderedSize: number;
startIndex: number;
totalAttachmentsCount: number;
selected: boolean;
}
) => {
const {
attachments,
@ -37,6 +44,7 @@ const Row = (
startIndex,
onClickAttachment,
totalAttachmentsCount,
selected,
} = props;
const isMessageVisible = useContext(IsMessageVisibleContext);
const moreMessagesOverlay = totalAttachmentsCount > 3;
@ -61,6 +69,7 @@ const Row = (
softCorners={true}
darkOverlay={showOverlay}
overlayText={showOverlay ? moreMessagesOverlayText : undefined}
dropShadow={selected}
/>
);
})}
@ -69,7 +78,9 @@ const Row = (
};
export const ImageGrid = (props: Props) => {
const { attachments, onError, onClickAttachment } = props;
const { attachments, onError, onClickAttachment, messageId } = props;
const selected = useMessageSelected(messageId);
if (!attachments || !attachments.length) {
return null;
@ -85,6 +96,7 @@ export const ImageGrid = (props: Props) => {
renderedSize={THUMBNAIL_SIDE}
startIndex={0}
totalAttachmentsCount={attachments.length}
selected={selected}
/>
</StyledImageGrid>
);
@ -101,6 +113,7 @@ export const ImageGrid = (props: Props) => {
renderedSize={THUMBNAIL_SIDE}
startIndex={0}
totalAttachmentsCount={attachments.length}
selected={selected}
/>
</StyledImageGrid>
);
@ -118,6 +131,7 @@ export const ImageGrid = (props: Props) => {
renderedSize={THUMBNAIL_SIDE}
startIndex={0}
totalAttachmentsCount={attachments.length}
selected={selected}
/>
<StyledImageGrid flexDirection={'column'}>
@ -128,6 +142,7 @@ export const ImageGrid = (props: Props) => {
renderedSize={columnImageSide}
startIndex={1}
totalAttachmentsCount={attachments.length}
selected={selected}
/>
</StyledImageGrid>
</StyledImageGrid>

@ -70,7 +70,7 @@ const handleAcceptConversationRequest = async (convoId: string) => {
await convo.setIsApproved(true, false);
await convo.commit();
await convo.addOutgoingApprovalMessage(Date.now());
await approveConvoAndSendResponse(convoId, true);
await approveConvoAndSendResponse(convoId);
};
export const ConversationMessageRequestButtons = () => {

@ -38,6 +38,8 @@ import { MIME } from '../../types';
import { AttachmentTypeWithPath } from '../../types/Attachment';
import {
THUMBNAIL_CONTENT_TYPE,
getAudioDuration,
getVideoDuration,
makeImageThumbnailBuffer,
makeVideoScreenshot,
} from '../../types/attachments/VisualAttachment';
@ -48,13 +50,14 @@ import { SplitViewContainer } from '../SplitViewContainer';
import { SessionButtonColor } from '../basic/SessionButton';
import { InConversationCallContainer } from '../calling/InConversationCallContainer';
import { LightboxGallery, MediaItemType } from '../lightbox/LightboxGallery';
import { ConversationHeaderWithDetails } from './ConversationHeader';
import { SessionRightPanelWithDetails } from './SessionRightPanel';
import { NoMessageInConversation } from './SubtleNotification';
import { MessageDetail } from './message/message-item/MessageDetail';
import { ConversationHeaderWithDetails } from './header/ConversationHeader';
import { isAudio } from '../../types/MIME';
import { HTMLDirection } from '../../util/i18n';
import { NoticeBanner } from '../NoticeBanner';
import { SessionSpinner } from '../basic/SessionSpinner';
import { RightPanel, StyledRightPanelContainer } from './right-panel/RightPanel';
const DEFAULT_JPEG_QUALITY = 0.85;
@ -67,12 +70,12 @@ export interface LightBoxOptions {
}
interface Props {
ourDisplayNameInProfile: string;
ourNumber: string;
selectedConversationKey: string;
selectedConversation?: ReduxConversationType;
messagesProps: Array<SortedMessageModelProps>;
selectedMessages: Array<string>;
showMessageDetails: boolean;
isRightPanelShowing: boolean;
hasOngoingCallWithFocusedConvo: boolean;
htmlDirection: HTMLDirection;
@ -233,9 +236,9 @@ export class SessionConversation extends React.Component<Props, State> {
const { isDraggingFile } = this.state;
const {
ourDisplayNameInProfile,
selectedConversation,
messagesProps,
showMessageDetails,
selectedMessages,
isRightPanelShowing,
lightBoxOptions,
@ -246,13 +249,29 @@ export class SessionConversation extends React.Component<Props, State> {
// return an empty message view
return <MessageView />;
}
// TODOLATER break showMessageDetails & selectionMode into it's own container component so we can use hooks to fetch relevant state from the store
// TODOLATER break selectionMode into it's own container component so we can use hooks to fetch relevant state from the store
const selectionMode = selectedMessages.length > 0;
const bannerText =
selectedConversation.hasOutdatedClient &&
selectedConversation.hasOutdatedClient !== ourDisplayNameInProfile
? window.i18n('disappearingMessagesModeOutdated', [selectedConversation.hasOutdatedClient])
: window.i18n('someOfYourDeviceUseOutdatedVersion');
return (
<SessionTheme>
<div className="conversation-header">
<ConversationHeaderWithDetails />
{selectedConversation?.hasOutdatedClient?.length ? (
<NoticeBanner
text={bannerText}
dismissCallback={() => {
const conversation = getConversationController().get(selectedConversation.id);
conversation.set({ hasOutdatedClient: undefined });
void conversation.commit();
}}
/>
) : null}
</div>
{isSelectedConvoInitialLoadingInProgress ? (
<ConvoLoadingSpinner />
@ -265,9 +284,6 @@ export class SessionConversation extends React.Component<Props, State> {
onKeyDown={this.onKeyDown}
role="navigation"
>
<div className={classNames('conversation-info-panel', showMessageDetails && 'show')}>
<MessageDetail />
</div>
{lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}
<div className="conversation-messages">
@ -294,14 +310,9 @@ export class SessionConversation extends React.Component<Props, State> {
htmlDirection={this.props.htmlDirection}
/>
</div>
<div
className={classNames(
'conversation-item__options-pane',
isRightPanelShowing && 'show'
)}
>
<SessionRightPanelWithDetails />
</div>
<StyledRightPanelContainer className={classNames(isRightPanelShowing && 'show')}>
<RightPanel />
</StyledRightPanelContainer>
</>
)}
</SessionTheme>
@ -433,19 +444,30 @@ export class SessionConversation extends React.Component<Props, State> {
const attachmentWithVideoPreview = await renderVideoPreview(contentType, file, fileName);
this.addAttachments([attachmentWithVideoPreview]);
} else {
this.addAttachments([
{
file,
size: file.size,
contentType,
fileName,
url: '',
isVoiceMessage: false,
fileSize: null,
screenshot: null,
thumbnail: null,
},
]);
const attachment: StagedAttachmentType = {
file,
size: file.size,
contentType,
fileName,
url: '',
isVoiceMessage: false,
fileSize: null,
screenshot: null,
thumbnail: null,
};
if (isAudio(contentType)) {
const objectUrl = URL.createObjectURL(file);
try {
const duration = await getAudioDuration({ objectUrl, contentType });
attachment.duration = duration;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
this.addAttachments([attachment]);
}
} catch (e) {
window?.log?.error(
@ -548,6 +570,10 @@ const renderVideoPreview = async (contentType: string, file: File, fileName: str
objectUrl,
contentType: type,
});
const duration = await getVideoDuration({
objectUrl,
contentType: type,
});
const data = await blobToArrayBuffer(thumbnail);
const url = arrayBufferToObjectURL({
data,
@ -558,6 +584,7 @@ const renderVideoPreview = async (contentType: string, file: File, fileName: str
size: file.size,
fileName,
contentType,
duration,
videoUrl: objectUrl,
url,
isVoiceMessage: false,

@ -91,7 +91,7 @@ type Props = {
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
show: boolean;
isModal?: boolean;
// NOTE Currently this doesn't work but we have a PR waiting to be merged to resolve this. William Grant 30/09/2022
// NOTE Currently this doesn't work but we have a PR waiting to be merged to resolve this
onKeyDown?: (event: any) => void;
};

@ -28,6 +28,7 @@ const LastSeenBarContainer = styled.div<{ darkMode?: boolean }>`
padding-bottom: 35px;
max-width: 300px;
align-self: center;
margin: 0 auto;
padding-top: 28px;
display: flex;
flex-direction: row;

@ -11,12 +11,14 @@ import {
PropsForExpirationTimer,
PropsForGroupInvitation,
PropsForGroupUpdate,
PropsForInteractionNotification,
} from '../../state/ducks/conversations';
import {
getOldBottomMessageId,
getOldTopMessageId,
getSortedMessagesTypesOfSelectedConversation,
} from '../../state/selectors/conversations';
import { useSelectedConversationKey } from '../../state/selectors/selectedConversation';
import { MessageDateBreak } from './message/message-item/DateBreak';
import { GroupInvitation } from './message/message-item/GroupInvitation';
import { GroupUpdateMessage } from './message/message-item/GroupUpdateMessage';
@ -24,10 +26,10 @@ import { Message } from './message/message-item/Message';
import { MessageRequestResponse } from './message/message-item/MessageRequestResponse';
import { CallNotification } from './message/message-item/notification-bubble/CallNotification';
import { useSelectedConversationKey } from '../../state/selectors/selectedConversation';
import { DataExtractionNotification } from './message/message-item/DataExtractionNotification';
import { SessionLastSeenIndicator } from './SessionLastSeenIndicator';
import { TimerNotification } from './TimerNotification';
import { InteractionNotification } from './message/message-item/InteractionNotification';
function isNotTextboxEvent(e: KeyboardEvent) {
return (e?.target as any)?.type === undefined;
@ -118,6 +120,7 @@ export const SessionMessagesList = (props: {
) : null;
const componentToMerge = [dateBreak, unreadIndicator];
if (messageProps.message?.messageType === 'group-notification') {
const msgProps = messageProps.message.props as PropsForGroupUpdate;
return [<GroupUpdateMessage key={messageId} {...msgProps} />, ...componentToMerge];
@ -155,6 +158,12 @@ export const SessionMessagesList = (props: {
return [<CallNotification key={messageId} {...msgProps} />, ...componentToMerge];
}
if (messageProps.message?.messageType === 'interaction-notification') {
const msgProps = messageProps.message.props as PropsForInteractionNotification;
return [<InteractionNotification key={messageId} {...msgProps} />, ...componentToMerge];
}
if (!messageProps) {
return null;
}

@ -22,9 +22,9 @@ import {
getSortedMessagesOfSelectedConversation,
} from '../../state/selectors/conversations';
import { getSelectedConversationKey } from '../../state/selectors/selectedConversation';
import { ConversationMessageRequestButtons } from './MessageRequestButtons';
import { SessionMessagesList } from './SessionMessagesList';
import { TypingBubble } from './TypingBubble';
import { ConversationMessageRequestButtons } from './MessageRequestButtons';
export type SessionMessageListProps = {
messageContainerRef: React.RefObject<HTMLDivElement>;
@ -54,13 +54,14 @@ type Props = SessionMessageListProps & {
const StyledMessagesContainer = styled.div`
display: flex;
flex-grow: 1;
gap: var(--margins-xxs);
gap: var(--margins-sm);
flex-direction: column-reverse;
position: relative;
overflow-x: hidden;
min-width: 370px;
scrollbar-width: 4px;
padding: var(--margins-sm) 0 var(--margins-lg);
padding-top: var(--margins-sm);
padding-bottom: var(--margins-xl);
.session-icon-button {
display: flex;
@ -72,6 +73,10 @@ const StyledMessagesContainer = styled.div`
}
`;
const StyledTypingBubble = styled(TypingBubble)`
margin: var(--margins-xs) var(--margins-lg) 0;
`;
class SessionMessagesListContainerInner extends React.Component<Props> {
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
@ -121,7 +126,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
ref={this.props.messageContainerRef}
data-testid="messages-container"
>
<TypingBubble
<StyledTypingBubble
conversationType={conversation.type}
isTyping={!!conversation.isTyping}
key="typing-bubble"

@ -1,370 +0,0 @@
import { compact, flatten } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useInterval from 'react-use/lib/useInterval';
import styled from 'styled-components';
import { Data } from '../../data/data';
import { SessionIconButton } from '../icon';
import {
deleteAllMessagesByConvoIdWithConfirmation,
setDisappearingMessagesByConvoId,
showAddModeratorsByConvoId,
showInviteContactByConvoId,
showLeaveGroupByConvoId,
showRemoveModeratorsByConvoId,
showUpdateGroupMembersByConvoId,
showUpdateGroupNameByConvoId,
} from '../../interactions/conversationInteractions';
import { Constants } from '../../session';
import { closeRightPanel } from '../../state/ducks/conversations';
import { isRightPanelShowing } from '../../state/selectors/conversations';
import {
useSelectedConversationKey,
useSelectedDisplayNameInProfile,
useSelectedIsActive,
useSelectedIsBlocked,
useSelectedIsGroup,
useSelectedIsKickedFromGroup,
useSelectedIsLeft,
useSelectedIsPublic,
useSelectedSubscriberCount,
useSelectedWeAreAdmin,
} from '../../state/selectors/selectedConversation';
import { getTimerOptions } from '../../state/selectors/timerOptions';
import { AttachmentTypeWithPath } from '../../types/Attachment';
import { getAbsoluteAttachmentPath } from '../../types/MessageAttachment';
import { Avatar, AvatarSize } from '../avatar/Avatar';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionDropdown } from '../basic/SessionDropdown';
import { SpacerLG } from '../basic/Text';
import { MediaItemType } from '../lightbox/LightboxGallery';
import { MediaGallery } from './media-gallery/MediaGallery';
async function getMediaGalleryProps(
conversationId: string
): Promise<{
documents: Array<MediaItemType>;
media: Array<MediaItemType>;
}> {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const rawMedia = await Data.getMessagesWithVisualMediaAttachments(
conversationId,
Constants.CONVERSATION.DEFAULT_MEDIA_FETCH_COUNT
);
const rawDocuments = await Data.getMessagesWithFileAttachments(
conversationId,
Constants.CONVERSATION.DEFAULT_DOCUMENTS_FETCH_COUNT
);
const media = flatten(
rawMedia.map(attributes => {
const { attachments, source, id, timestamp, serverTimestamp, received_at } = attributes;
return (attachments || [])
.filter(
(attachment: AttachmentTypeWithPath) =>
attachment.thumbnail && !attachment.pending && !attachment.error
)
.map((attachment: AttachmentTypeWithPath, index: number) => {
const { thumbnail } = attachment;
const mediaItem: MediaItemType = {
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail ? getAbsoluteAttachmentPath(thumbnail.path) : undefined,
contentType: attachment.contentType || '',
index,
messageTimestamp: timestamp || serverTimestamp || received_at || 0,
messageSender: source,
messageId: id,
attachment,
};
return mediaItem;
});
})
);
// Unlike visual media, only one non-image attachment is supported
const documents = rawDocuments.map(attributes => {
// this is to not fail if the attachment is invalid (could be a Long Attachment type which is not supported)
if (!attributes.attachments?.length) {
// window?.log?.info(
// 'Got a message with an empty list of attachment. Skipping...'
// );
return null;
}
const attachment = attributes.attachments[0];
const { source, id, timestamp, serverTimestamp, received_at } = attributes;
return {
contentType: attachment.contentType,
index: 0,
attachment,
messageTimestamp: timestamp || serverTimestamp || received_at || 0,
messageSender: source,
messageId: id,
};
});
return {
media,
documents: compact(documents), // remove null
};
}
const HeaderItem = () => {
const selectedConvoKey = useSelectedConversationKey();
const dispatch = useDispatch();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroup();
if (!selectedConvoKey) {
return null;
}
const showInviteContacts = isGroup && !isKickedFromGroup && !isBlocked && !left;
return (
<div className="group-settings-header">
<SessionIconButton
iconType="chevron"
iconSize="medium"
iconRotation={270}
onClick={() => {
dispatch(closeRightPanel());
}}
style={{ position: 'absolute' }}
dataTestId="back-button-conversation-options"
/>
<Avatar size={AvatarSize.XL} pubkey={selectedConvoKey} />
{showInviteContacts && (
<SessionIconButton
iconType="addUser"
iconSize="medium"
onClick={() => {
if (selectedConvoKey) {
showInviteContactByConvoId(selectedConvoKey);
}
}}
dataTestId="add-user-button"
/>
)}
</div>
);
};
const StyledLeaveButton = styled.div`
width: 100%;
.session-button {
margin-top: auto;
width: 100%;
min-height: calc(var(--composition-container-height) + 1px); // include border in height
flex-shrink: 0;
align-items: center;
border-top: 1px solid var(--border-color);
border-radius: 0px;
&:not(.disabled) {
&:hover {
background-color: var(--button-solid-background-hover-color);
}
}
}
`;
const StyledGroupSettingsItem = styled.div`
display: flex;
align-items: center;
min-height: 3rem;
font-size: var(--font-size-md);
color: var(--right-panel-item-text-color);
background-color: var(--right-panel-item-background-color);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
width: -webkit-fill-available;
padding: 0 var(--margins-md);
transition: var(--default-duration);
cursor: pointer;
&:hover {
background-color: var(--right-panel-item-background-hover-color);
}
`;
const StyledName = styled.h4`
padding-inline: var(--margins-md);
font-size: var(--font-size-md);
`;
export const SessionRightPanelWithDetails = () => {
const [documents, setDocuments] = useState<Array<MediaItemType>>([]);
const [media, setMedia] = useState<Array<MediaItemType>>([]);
const selectedConvoKey = useSelectedConversationKey();
const isShowing = useSelector(isRightPanelShowing);
const subscriberCount = useSelectedSubscriberCount();
const isActive = useSelectedIsActive();
const displayNameInProfile = useSelectedDisplayNameInProfile();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroup();
const isPublic = useSelectedIsPublic();
const weAreAdmin = useSelectedWeAreAdmin();
useEffect(() => {
let isRunning = true;
if (isShowing && selectedConvoKey) {
// eslint-disable-next-line more/no-then
void getMediaGalleryProps(selectedConvoKey).then(results => {
if (isRunning) {
setDocuments(results.documents);
setMedia(results.media);
}
});
}
return () => {
isRunning = false;
};
}, [isShowing, selectedConvoKey]);
useInterval(async () => {
if (isShowing && selectedConvoKey) {
const results = await getMediaGalleryProps(selectedConvoKey);
if (results.documents.length !== documents.length || results.media.length !== media.length) {
setDocuments(results.documents);
setMedia(results.media);
}
}
}, 10000);
const showMemberCount = !!(subscriberCount && subscriberCount > 0);
const commonNoShow = isKickedFromGroup || left || isBlocked || !isActive;
const hasDisappearingMessages = !isPublic && !commonNoShow;
const leaveGroupString = isPublic
? window.i18n('leaveGroup')
: isKickedFromGroup
? window.i18n('youGotKickedFromGroup')
: left
? window.i18n('youLeftTheGroup')
: window.i18n('leaveGroup');
const timerOptions = useSelector(getTimerOptions).timerOptions;
if (!selectedConvoKey) {
return null;
}
const disappearingMessagesOptions = timerOptions.map(option => {
return {
content: option.name,
onClick: () => {
void setDisappearingMessagesByConvoId(selectedConvoKey, option.value);
},
};
});
const showUpdateGroupNameButton =
isGroup && (!isPublic || (isPublic && weAreAdmin)) && !commonNoShow;
const showAddRemoveModeratorsButton = weAreAdmin && !commonNoShow && isPublic;
const showUpdateGroupMembersButton = !isPublic && isGroup && !commonNoShow;
const deleteConvoAction = isPublic
? () => {
deleteAllMessagesByConvoIdWithConfirmation(selectedConvoKey); // TODOLATER this does not delete the public group and showLeaveGroupByConvoId is not only working for closed groups
}
: () => {
showLeaveGroupByConvoId(selectedConvoKey);
};
return (
<div className="group-settings">
<HeaderItem />
<StyledName data-testid="right-panel-group-name">{displayNameInProfile}</StyledName>
{showMemberCount && (
<>
<SpacerLG />
<div role="button" className="subtle">
{window.i18n('members', [`${subscriberCount}`])}
</div>
<SpacerLG />
</>
)}
{showUpdateGroupNameButton && (
<StyledGroupSettingsItem
className="group-settings-item"
role="button"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
await showUpdateGroupNameByConvoId(selectedConvoKey);
}}
>
{isPublic ? window.i18n('editGroup') : window.i18n('editGroupName')}
</StyledGroupSettingsItem>
)}
{showAddRemoveModeratorsButton && (
<>
<StyledGroupSettingsItem
className="group-settings-item"
role="button"
onClick={() => {
showAddModeratorsByConvoId(selectedConvoKey);
}}
>
{window.i18n('addModerators')}
</StyledGroupSettingsItem>
<StyledGroupSettingsItem
className="group-settings-item"
role="button"
onClick={() => {
showRemoveModeratorsByConvoId(selectedConvoKey);
}}
>
{window.i18n('removeModerators')}
</StyledGroupSettingsItem>
</>
)}
{showUpdateGroupMembersButton && (
<StyledGroupSettingsItem
className="group-settings-item"
role="button"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
await showUpdateGroupMembersByConvoId(selectedConvoKey);
}}
>
{window.i18n('groupMembers')}
</StyledGroupSettingsItem>
)}
{hasDisappearingMessages && (
<SessionDropdown
label={window.i18n('disappearingMessages')}
options={disappearingMessagesOptions}
dataTestId="disappearing-messages-dropdown"
/>
)}
<MediaGallery documents={documents} media={media} />
{isGroup && (
<StyledLeaveButton>
<SessionButton
text={leaveGroupString}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
disabled={isKickedFromGroup || left}
onClick={deleteConvoAction}
/>
</StyledLeaveButton>
)}
</div>
);
};

@ -1,6 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { useIsIncomingRequest } from '../../hooks/useParamSelector';
import {
getSelectedHasMessages,
hasSelectedConversationIncomingMessages,
@ -9,12 +10,11 @@ import {
getSelectedCanWrite,
useSelectedConversationKey,
useSelectedHasDisabledBlindedMsgRequests,
useSelectedIsNoteToSelf,
useSelectedNicknameOrProfileNameOrShortenedPubkey,
useSelectedisNoteToSelf,
} from '../../state/selectors/selectedConversation';
import { LocalizerKeys } from '../../types/LocalizerKeys';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
import { useIsIncomingRequest } from '../../hooks/useParamSelector';
const Container = styled.div`
display: flex;
@ -60,10 +60,10 @@ export const NoMessageInConversation = () => {
const hasMessage = useSelector(getSelectedHasMessages);
const isMe = useSelectedisNoteToSelf();
const isMe = useSelectedIsNoteToSelf();
const canWrite = useSelector(getSelectedCanWrite);
const privateBlindedAndBlockingMsgReqs = useSelectedHasDisabledBlindedMsgRequests();
// TODOLATER use this selector accross the whole application (left pane excluded)
// TODOLATER use this selector across the whole application (left pane excluded)
const nameToRender = useSelectedNicknameOrProfileNameOrShortenedPubkey();
if (!selectedConversation || hasMessage) {

@ -1,51 +1,232 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { PropsForExpirationTimer } from '../../state/ducks/conversations';
import { NotificationBubble } from './message/message-item/notification-bubble/NotificationBubble';
import { ReadableMessage } from './message/message-item/ReadableMessage';
import { assertUnreachable } from '../../types/sqlSharedTypes';
export const TimerNotification = (props: PropsForExpirationTimer) => {
const { messageId, receivedAt, isUnread, pubkey, profileName, timespan, type, disabled } = props;
import { isLegacyDisappearingModeEnabled } from '../../session/disappearing_messages/legacy';
import { UserUtils } from '../../session/utils';
import {
useSelectedConversationDisappearingMode,
useSelectedConversationKey,
useSelectedExpireTimer,
useSelectedIsGroupOrCommunity,
useSelectedIsGroupV2,
useSelectedIsNoteToSelf,
useSelectedIsPrivate,
useSelectedIsPrivateFriend,
} from '../../state/selectors/selectedConversation';
import { ReleasedFeatures } from '../../util/releaseFeature';
import { Flex } from '../basic/Flex';
import { SpacerMD, TextWithChildren } from '../basic/Text';
import { ExpirableReadableMessage } from './message/message-item/ExpirableReadableMessage';
// eslint-disable-next-line import/order
import { ConversationInteraction } from '../../interactions';
import { getConversationController } from '../../session/conversations';
import { updateConfirmModal } from '../../state/ducks/modalDialog';
import { SessionButtonColor } from '../basic/SessionButton';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
import { SessionIcon } from '../icon';
const contact = profileName || pubkey;
const FollowSettingButton = styled.button`
color: var(--primary-color);
`;
function useFollowSettingsButtonClick(
props: Pick<
PropsForExpirationTimer,
'disabled' | 'expirationMode' | 'timespanText' | 'timespanSeconds'
>
) {
const selectedConvoKey = useSelectedConversationKey();
const dispatch = useDispatch();
const onExit = () => dispatch(updateConfirmModal(null));
const doIt = () => {
const mode =
props.expirationMode === 'deleteAfterRead'
? window.i18n('timerModeRead')
: window.i18n('timerModeSent');
const message = props.disabled
? window.i18n('followSettingDisabled')
: window.i18n('followSettingTimeAndType', [props.timespanText, mode]);
const okText = props.disabled ? window.i18n('confirm') : window.i18n('set');
dispatch(
updateConfirmModal({
title: window.i18n('followSetting'),
message,
okText,
okTheme: SessionButtonColor.Danger,
onClickOk: async () => {
if (!selectedConvoKey) {
throw new Error('no selected convokey');
}
const convo = getConversationController().get(selectedConvoKey);
if (!convo) {
throw new Error('no selected convo');
}
if (!convo.isPrivate()) {
throw new Error('follow settings only work for private chats');
}
if (props.expirationMode === 'legacy') {
throw new Error('follow setting does not apply with legacy');
}
if (props.expirationMode !== 'off' && !props.timespanSeconds) {
throw new Error('non-off mode requires seconds arg to be given');
}
await ConversationInteraction.setDisappearingMessagesByConvoId(
selectedConvoKey,
props.expirationMode,
props.timespanSeconds ?? undefined
);
},
showExitIcon: false,
onClickClose: onExit,
})
);
};
return { doIt };
}
function useAreSameThanOurSide(
props: Pick<PropsForExpirationTimer, 'disabled' | 'expirationMode' | 'timespanSeconds'>
) {
const selectedMode = useSelectedConversationDisappearingMode();
const selectedTimestan = useSelectedExpireTimer();
if (props.disabled && (selectedMode === 'off' || selectedMode === undefined)) {
return true;
}
if (props.expirationMode === selectedMode && props.timespanSeconds === selectedTimestan) {
return true;
}
return false;
}
const FollowSettingsButton = (props: PropsForExpirationTimer) => {
const v2Released = ReleasedFeatures.isUserConfigFeatureReleasedCached();
const isPrivateAndFriend = useSelectedIsPrivateFriend();
const click = useFollowSettingsButtonClick(props);
const areSameThanOurs = useAreSameThanOurSide(props);
if (!v2Released || !isPrivateAndFriend) {
return null;
}
if (
props.type === 'fromMe' ||
props.type === 'fromSync' ||
props.pubkey === UserUtils.getOurPubKeyStrFromCache() ||
areSameThanOurs ||
props.expirationMode === 'legacy' // we cannot follow settings with legacy mode
) {
return null;
}
let textToRender: string | undefined;
return (
<FollowSettingButton
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={() => click.doIt()}
>
{window.i18n('followSetting')}
</FollowSettingButton>
);
};
function useTextToRender(props: PropsForExpirationTimer) {
const { pubkey, profileName, expirationMode, timespanText, type, disabled } = props;
const isV2Released = ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached();
const isPrivate = useSelectedIsPrivate();
const isMe = useSelectedIsNoteToSelf();
const ownSideOnly = isV2Released && isPrivate && !isMe;
// when v2 is released, and this is a private chat, the settings are for the outgoing messages of whoever made the change only
const contact = profileName || pubkey;
// TODO legacy messages support will be removed in a future release
const mode = isLegacyDisappearingModeEnabled(expirationMode)
? null
: expirationMode === 'deleteAfterRead'
? window.i18n('timerModeRead')
: window.i18n('timerModeSent');
switch (type) {
case 'fromOther':
textToRender = disabled
? window.i18n('disabledDisappearingMessages', [contact, timespan])
: window.i18n('theyChangedTheTimer', [contact, timespan]);
break;
return disabled
? window.i18n(
ownSideOnly ? 'theyDisabledTheirDisappearingMessages' : 'disabledDisappearingMessages',
[contact, timespanText]
)
: mode
? window.i18n(ownSideOnly ? 'theySetTheirDisappearingMessages' : 'theyChangedTheTimer', [
contact,
timespanText,
mode,
])
: window.i18n('theyChangedTheTimerLegacy', [contact, timespanText]);
case 'fromMe':
textToRender = disabled
? window.i18n('youDisabledDisappearingMessages')
: window.i18n('youChangedTheTimer', [timespan]);
break;
case 'fromSync':
textToRender = disabled
? window.i18n('disappearingMessagesDisabled')
: window.i18n('timerSetOnSync', [timespan]);
break;
return disabled
? window.i18n(
ownSideOnly ? 'youDisabledYourDisappearingMessages' : 'youDisabledDisappearingMessages'
)
: mode
? window.i18n(ownSideOnly ? 'youSetYourDisappearingMessages' : 'youChangedTheTimer', [
timespanText,
mode,
])
: window.i18n('youChangedTheTimerLegacy', [timespanText]);
default:
assertUnreachable(type, `TimerNotification: Missing case error "${type}"`);
}
throw new Error('unhandled case');
}
export const TimerNotification = (props: PropsForExpirationTimer) => {
const { messageId } = props;
const textToRender = useTextToRender(props);
const isGroupOrCommunity = useSelectedIsGroupOrCommunity();
const isGroupV2 = useSelectedIsGroupV2();
// renderOff is true when the update is put to off, or when we have a legacy group control message (as they are not expiring at all)
const renderOffIcon = props.disabled || (isGroupOrCommunity && !isGroupV2);
if (!textToRender || textToRender.length === 0) {
throw new Error('textToRender invalid key used TimerNotification');
}
return (
<ReadableMessage
<ExpirableReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
isControlMessage={true}
key={`readable-message-${messageId}`}
dataTestId={'disappear-control-message'}
>
<NotificationBubble
iconType="stopwatch"
iconColor="inherit"
notificationText={textToRender}
/>
</ReadableMessage>
<Flex
container={true}
flexDirection="column"
alignItems="center"
justifyContent="center"
width="90%"
maxWidth="700px"
margin="5px auto 10px auto" // top margin is smaller that bottom one to make the stopwatch icon of expirable message closer to its content
padding="5px 10px"
style={{ textAlign: 'center' }}
>
{renderOffIcon && (
<>
<SessionIcon
iconType="timerFixed"
iconSize={'tiny'}
iconColor="var(--text-secondary-color)"
/>
<SpacerMD />
</>
)}
<TextWithChildren subtle={true}>
<SessionHtmlRenderer html={textToRender} />
</TextWithChildren>
<FollowSettingsButton {...props} />
</Flex>
</ExpirableReadableMessage>
);
};

@ -0,0 +1,56 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { isMessageSelectionMode } from '../../../state/selectors/conversations';
import { openRightPanel } from '../../../state/ducks/conversations';
import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation';
import { Flex } from '../../basic/Flex';
import { AvatarHeader, CallButton } from './ConversationHeaderItems';
import { SelectionOverlay } from './ConversationHeaderSelectionOverlay';
import { ConversationHeaderTitle } from './ConversationHeaderTitle';
export const ConversationHeaderWithDetails = () => {
const isSelectionMode = useSelector(isMessageSelectionMode);
const selectedConvoKey = useSelectedConversationKey();
const dispatch = useDispatch();
if (!selectedConvoKey) {
return null;
}
return (
<div className="module-conversation-header">
<Flex
container={true}
justifyContent={'flex-end'}
alignItems="center"
width="100%"
flexGrow={1}
>
<ConversationHeaderTitle />
{!isSelectionMode && (
<Flex
container={true}
flexDirection="row"
alignItems="center"
flexGrow={0}
flexShrink={0}
>
<CallButton />
<AvatarHeader
onAvatarClick={() => {
dispatch(openRightPanel());
}}
pubkey={selectedConvoKey}
/>
</Flex>
)}
</Flex>
{isSelectionMode && <SelectionOverlay />}
</div>
);
};

@ -0,0 +1,93 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { callRecipient } from '../../../interactions/conversationInteractions';
import { getHasIncomingCall, getHasOngoingCall } from '../../../state/selectors/call';
import {
useSelectedConversationKey,
useSelectedIsActive,
useSelectedIsBlocked,
useSelectedIsNoteToSelf,
useSelectedIsPrivate,
useSelectedIsPrivateFriend,
} from '../../../state/selectors/selectedConversation';
import { Avatar, AvatarSize } from '../../avatar/Avatar';
import { SessionIconButton } from '../../icon';
export const AvatarHeader = (props: {
pubkey: string;
onAvatarClick?: (pubkey: string) => void;
}) => {
const { pubkey, onAvatarClick } = props;
return (
<span className="module-conversation-header__avatar">
<Avatar
size={AvatarSize.S}
onAvatarClick={() => {
if (onAvatarClick) {
onAvatarClick(pubkey);
}
}}
pubkey={pubkey}
dataTestId="conversation-options-avatar"
/>
</span>
);
};
export const BackButton = (props: { onGoBack: () => void; showBackButton: boolean }) => {
const { onGoBack, showBackButton } = props;
if (!showBackButton) {
return null;
}
return (
<SessionIconButton
iconType="chevron"
iconSize="large"
iconRotation={90}
onClick={onGoBack}
dataTestId="back-button-message-details"
/>
);
};
export const CallButton = () => {
const isPrivate = useSelectedIsPrivate();
const isBlocked = useSelectedIsBlocked();
const activeAt = useSelectedIsActive();
const isMe = useSelectedIsNoteToSelf();
const selectedConvoKey = useSelectedConversationKey();
const hasIncomingCall = useSelector(getHasIncomingCall);
const hasOngoingCall = useSelector(getHasOngoingCall);
const canCall = !(hasIncomingCall || hasOngoingCall);
const isPrivateAndFriend = useSelectedIsPrivateFriend();
if (
!isPrivate ||
isMe ||
!selectedConvoKey ||
isBlocked ||
!activeAt ||
!isPrivateAndFriend // call requires us to be friends
) {
return null;
}
return (
<SessionIconButton
iconType="phone"
iconSize="large"
iconPadding="2px"
// negative margin to keep conversation header title centered
margin="0 10px 0 -32px"
onClick={() => {
void callRecipient(selectedConvoKey, canCall);
}}
dataTestId="call-button"
/>
);
};

@ -0,0 +1,70 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
deleteMessagesById,
deleteMessagesByIdForEveryone,
} from '../../../interactions/conversations/unsendingInteractions';
import { resetSelectedMessageIds } from '../../../state/ducks/conversations';
import { getSelectedMessageIds } from '../../../state/selectors/conversations';
import {
useSelectedConversationKey,
useSelectedIsPublic,
} from '../../../state/selectors/selectedConversation';
import {
SessionButton,
SessionButtonColor,
SessionButtonShape,
SessionButtonType,
} from '../../basic/SessionButton';
import { SessionIconButton } from '../../icon';
function onDeleteSelectedMessagesForEveryone(
selectedConversationKey: string,
selectedMessageIds: Array<string>
) {
if (selectedConversationKey) {
void deleteMessagesByIdForEveryone(selectedMessageIds, selectedConversationKey);
}
}
export const SelectionOverlay = () => {
const selectedMessageIds = useSelector(getSelectedMessageIds);
const selectedConversationKey = useSelectedConversationKey();
const isPublic = useSelectedIsPublic();
const dispatch = useDispatch();
function onCloseOverlay() {
dispatch(resetSelectedMessageIds());
}
const isOnlyServerDeletable = isPublic;
return (
<div className="message-selection-overlay">
<div className="close-button">
<SessionIconButton iconType="exit" iconSize="medium" onClick={onCloseOverlay} />
</div>
<div className="button-group">
<SessionButton
buttonColor={SessionButtonColor.Danger}
buttonShape={SessionButtonShape.Square}
buttonType={SessionButtonType.Solid}
text={window.i18n('delete')}
onClick={() => {
if (selectedConversationKey) {
if (isOnlyServerDeletable) {
void onDeleteSelectedMessagesForEveryone(
selectedConversationKey,
selectedMessageIds
);
} else {
void deleteMessagesById(selectedMessageIds, selectedConversationKey);
}
}
}}
/>
</div>
</div>
);
};

@ -0,0 +1,166 @@
import React from 'react';
import styled, { CSSProperties } from 'styled-components';
import { Flex } from '../../basic/Flex';
import { SessionIconButton } from '../../icon';
import { SubtitleStrings, SubtitleStringsType } from './ConversationHeaderTitle';
function loadDataTestId(currentSubtitle: SubtitleStringsType) {
if (currentSubtitle === 'disappearingMessages') {
return 'disappear-messages-type-and-time';
}
return 'conversation-header-subtitle';
}
export const StyledSubtitleContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0 auto;
min-width: 230px;
div:first-child {
span:last-child {
margin-bottom: 0;
}
}
`;
export const StyledSubtitleDotMenu = styled(Flex)``;
const StyledSubtitleDot = styled.span<{ active: boolean }>`
border-radius: 50%;
background-color: ${props =>
props.active ? 'var(--text-primary-color)' : 'var(--text-secondary-color)'};
height: 5px;
width: 5px;
margin: 0 2px;
`;
export const SubtitleDotMenu = ({
id,
selectedOptionIndex,
optionsCount,
style,
}: {
id: string;
selectedOptionIndex: number;
optionsCount: number;
style: CSSProperties;
}) => (
<StyledSubtitleDotMenu id={id} container={true} alignItems={'center'} style={style}>
{Array(optionsCount)
.fill(0)
.map((_, index) => {
return (
<StyledSubtitleDot
key={`subtitleDotMenu-${id}-${index}`}
active={selectedOptionIndex === index}
/>
);
})}
</StyledSubtitleDotMenu>
);
type ConversationHeaderSubtitleProps = {
subtitlesArray: Array<SubtitleStringsType>;
subtitleStrings: SubtitleStrings;
currentSubtitle: SubtitleStringsType;
setCurrentSubtitle: (index: SubtitleStringsType) => void;
onClickFunction: () => void;
showDisappearingMessageIcon: boolean;
};
export const ConversationHeaderSubtitle = (props: ConversationHeaderSubtitleProps) => {
const {
subtitlesArray,
subtitleStrings,
currentSubtitle,
setCurrentSubtitle,
onClickFunction,
showDisappearingMessageIcon,
} = props;
const handleTitleCycle = (direction: 1 | -1) => {
let newIndex = subtitlesArray.indexOf(currentSubtitle) + direction;
if (newIndex > subtitlesArray.length - 1) {
newIndex = 0;
}
if (newIndex < 0) {
newIndex = subtitlesArray.length - 1;
}
if (subtitlesArray[newIndex]) {
setCurrentSubtitle(subtitlesArray[newIndex]);
}
};
return (
<StyledSubtitleContainer>
<Flex
container={true}
flexDirection={'row'}
justifyContent={subtitlesArray.length < 2 ? 'center' : 'space-between'}
alignItems={'center'}
width={'100%'}
>
<SessionIconButton
iconColor={'var(--button-icon-stroke-selected-color)'}
iconSize={'small'}
iconType="chevron"
iconRotation={90}
margin={'0 3px 0 0'}
onClick={() => {
handleTitleCycle(-1);
}}
isHidden={subtitlesArray.length < 2}
tabIndex={0}
/>
{showDisappearingMessageIcon && (
<SessionIconButton
iconColor={'var(--button-icon-stroke-selected-color)'}
iconSize={'tiny'}
iconType="timerFixed"
margin={'0 var(--margins-xs) 0 0'}
/>
)}
<span
role="button"
className="module-conversation-header__title-text"
onClick={onClickFunction}
onKeyPress={(e: any) => {
if (e.key === 'Enter') {
e.preventDefault();
onClickFunction();
}
}}
tabIndex={0}
data-testid={loadDataTestId(currentSubtitle)}
>
{subtitleStrings[currentSubtitle]}
</span>
<SessionIconButton
iconColor={'var(--button-icon-stroke-selected-color)'}
iconSize={'small'}
iconType="chevron"
iconRotation={270}
margin={'0 0 0 3px'}
onClick={() => {
handleTitleCycle(1);
}}
isHidden={subtitlesArray.length < 2}
tabIndex={0}
/>
</Flex>
<SubtitleDotMenu
id={'conversation-header-subtitle-dots'}
selectedOptionIndex={subtitlesArray.indexOf(currentSubtitle)}
optionsCount={subtitlesArray.length}
style={{ display: subtitlesArray.length < 2 ? 'none' : undefined, margin: '8px 0' }}
/>
</StyledSubtitleContainer>
);
};

@ -0,0 +1,183 @@
import { isEmpty } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDisappearingMessageSettingText } from '../../../hooks/useParamSelector';
import { useIsRightPanelShowing } from '../../../hooks/useUI';
import { closeRightPanel, openRightPanel } from '../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../state/ducks/section';
import {
useSelectedConversationDisappearingMode,
useSelectedConversationKey,
useSelectedIsGroupOrCommunity,
useSelectedIsKickedFromGroup,
useSelectedIsNoteToSelf,
useSelectedIsPublic,
useSelectedMembers,
useSelectedNicknameOrProfileNameOrShortenedPubkey,
useSelectedNotificationSetting,
useSelectedSubscriberCount,
} from '../../../state/selectors/selectedConversation';
import { ConversationHeaderSubtitle } from './ConversationHeaderSubtitle';
export type SubtitleStrings = Record<string, string> & {
notifications?: string;
members?: string;
disappearingMessages?: string;
};
export type SubtitleStringsType = keyof Pick<
SubtitleStrings,
'notifications' | 'members' | 'disappearingMessages'
>;
export const ConversationHeaderTitle = () => {
const dispatch = useDispatch();
const convoId = useSelectedConversationKey();
const convoName = useSelectedNicknameOrProfileNameOrShortenedPubkey();
const notificationSetting = useSelectedNotificationSetting();
const isRightPanelOn = useIsRightPanelShowing();
const subscriberCount = useSelectedSubscriberCount();
const isPublic = useSelectedIsPublic();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const isMe = useSelectedIsNoteToSelf();
const isGroup = useSelectedIsGroupOrCommunity();
const members = useSelectedMembers();
const expirationMode = useSelectedConversationDisappearingMode();
const disappearingMessageSubtitle = useDisappearingMessageSettingText({
convoId,
abbreviate: true,
});
const [visibleSubtitle, setVisibleSubtitle] = useState<SubtitleStringsType>(
'disappearingMessages'
);
const [subtitleStrings, setSubtitleStrings] = useState<SubtitleStrings>({});
const [subtitleArray, setSubtitleArray] = useState<Array<SubtitleStringsType>>([]);
const { i18n } = window;
const notificationSubtitle = useMemo(
() => (notificationSetting ? i18n('notificationSubtitle', [notificationSetting]) : null),
[i18n, notificationSetting]
);
const memberCountSubtitle = useMemo(() => {
let memberCount = 0;
if (isGroup) {
if (isPublic) {
memberCount = subscriberCount || 0;
} else {
memberCount = members.length;
}
}
if (isGroup && memberCount > 0 && !isKickedFromGroup) {
const count = String(memberCount);
return isPublic ? i18n('activeMembers', [count]) : i18n('members', [count]);
}
return null;
}, [i18n, isGroup, isKickedFromGroup, isPublic, members.length, subscriberCount]);
const handleRightPanelToggle = () => {
if (isRightPanelOn) {
dispatch(closeRightPanel());
return;
}
// NOTE If disappearing messages is defined we must show it first
if (visibleSubtitle === 'disappearingMessages') {
dispatch(
setRightOverlayMode({
type: 'disappearing_messages',
params: null,
})
);
} else {
dispatch(resetRightOverlayMode());
}
dispatch(openRightPanel());
};
useEffect(() => {
if (visibleSubtitle !== 'disappearingMessages') {
if (!isEmpty(disappearingMessageSubtitle)) {
setVisibleSubtitle('disappearingMessages');
} else {
setVisibleSubtitle('notifications');
}
}
// We only want this to change when a new conversation is selected or disappearing messages is toggled
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convoId, disappearingMessageSubtitle]);
useEffect(() => {
const newSubtitlesArray: any = [];
const newSubtitlesStrings: any = {};
if (disappearingMessageSubtitle) {
newSubtitlesStrings.disappearingMessages = disappearingMessageSubtitle;
newSubtitlesArray.push('disappearingMessages');
}
if (notificationSubtitle) {
newSubtitlesStrings.notifications = notificationSubtitle;
newSubtitlesArray.push('notifications');
}
if (memberCountSubtitle) {
newSubtitlesStrings.members = memberCountSubtitle;
newSubtitlesArray.push('members');
}
if (newSubtitlesArray.indexOf(visibleSubtitle) < 0) {
setVisibleSubtitle('notifications');
}
setSubtitleStrings(newSubtitlesStrings);
setSubtitleArray(newSubtitlesArray);
}, [disappearingMessageSubtitle, memberCountSubtitle, notificationSubtitle, visibleSubtitle]);
return (
<div className="module-conversation-header__title-container">
<div className="module-conversation-header__title-flex">
<div className="module-conversation-header__title">
{isMe ? (
<span
onClick={handleRightPanelToggle}
role="button"
data-testid="header-conversation-name"
>
{i18n('noteToSelf')}
</span>
) : (
<span
className="module-contact-name__profile-name"
onClick={handleRightPanelToggle}
role="button"
data-testid="header-conversation-name"
>
{convoName}
</span>
)}
{subtitleArray.indexOf(visibleSubtitle) > -1 && (
<ConversationHeaderSubtitle
currentSubtitle={visibleSubtitle}
setCurrentSubtitle={setVisibleSubtitle}
subtitlesArray={subtitleArray}
subtitleStrings={subtitleStrings}
onClickFunction={handleRightPanelToggle}
showDisappearingMessageIcon={
visibleSubtitle === 'disappearingMessages' && expirationMode !== 'off'
}
/>
)}
</div>
</div>
</div>
);
};

@ -1,9 +1,9 @@
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { MediaItemType } from '../../lightbox/LightboxGallery';
import { AttachmentSection } from './AttachmentSection';
import { EmptyState } from './EmptyState';
import { MediaItemType } from '../../lightbox/LightboxGallery';
type Props = {
documents: Array<MediaItemType>;

@ -106,6 +106,9 @@ export const ClickToTrustSender = (props: { messageId: string }) => {
})
);
},
onClickClose: () => {
window.inboxStore?.dispatch(updateConfirmModal(null));
},
})
);
};

@ -1,8 +1,8 @@
import classNames from 'classnames';
import { clone } from 'lodash';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import classNames from 'classnames';
import { clone } from 'lodash';
import { Data } from '../../../../data/data';
import { MessageModelType, MessageRenderingProps } from '../../../../models/messageType';
import {
@ -10,6 +10,8 @@ import {
showLightBox,
toggleSelectedMessageId,
} from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { useMessageSelected } from '../../../../state/selectors';
import {
getMessageAttachmentProps,
isMessageSelectionMode,
@ -31,7 +33,7 @@ import { AudioPlayerWithEncryptedFile } from '../../H5AudioPlayer';
import { ImageGrid } from '../../ImageGrid';
import { LightBoxOptions } from '../../SessionConversation';
import { ClickToTrustSender } from './ClickToTrustSender';
import { StyledMessageHighlighter } from './MessageContent';
import { MessageHighlighter } from './MessageHighlighter';
export type MessageAttachmentSelectorProps = Pick<
MessageRenderingProps,
@ -52,7 +54,7 @@ type Props = {
highlight?: boolean;
};
const StyledAttachmentContainer = styled.div<{
const StyledImageGridContainer = styled.div<{
messageDirection: MessageModelType;
}>`
text-align: center;
@ -62,13 +64,20 @@ const StyledAttachmentContainer = styled.div<{
justify-content: ${props => (props.messageDirection === 'incoming' ? 'flex-start' : 'flex-end')};
`;
const StyledGenericAttachmentContainer = styled(MessageHighlighter)<{ selected: boolean }>`
${props => props.selected && 'box-shadow: var(--drop-shadow);'}
`;
export const MessageAttachment = (props: Props) => {
const { messageId, imageBroken, handleImageError, highlight = false } = props;
const dispatch = useDispatch();
const attachmentProps = useSelector(state => getMessageAttachmentProps(state as any, messageId));
const attachmentProps = useSelector((state: StateType) =>
getMessageAttachmentProps(state, messageId)
);
const multiSelectMode = useSelector(isMessageSelectionMode);
const selected = useMessageSelected(messageId);
const onClickOnImageGrid = useCallback(
(attachment: AttachmentTypeWithPath | AttachmentType) => {
if (multiSelectMode) {
@ -135,21 +144,22 @@ export const MessageAttachment = (props: Props) => {
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
return (
<StyledMessageHighlighter highlight={highlight}>
<StyledAttachmentContainer messageDirection={direction}>
<MessageHighlighter highlight={highlight}>
<StyledImageGridContainer messageDirection={direction}>
<ImageGrid
messageId={messageId}
attachments={attachments}
onError={handleImageError}
onClickAttachment={onClickOnImageGrid}
/>
</StyledAttachmentContainer>
</StyledMessageHighlighter>
</StyledImageGridContainer>
</MessageHighlighter>
);
}
if (!firstAttachment.pending && !firstAttachment.error && isAudio(attachments)) {
return (
<StyledMessageHighlighter
<MessageHighlighter
highlight={highlight}
role="main"
onClick={(e: any) => {
@ -165,14 +175,18 @@ export const MessageAttachment = (props: Props) => {
contentType={firstAttachment.contentType}
messageId={messageId}
/>
</StyledMessageHighlighter>
</MessageHighlighter>
);
}
const { pending, fileName, fileSize, contentType } = firstAttachment;
const extension = getExtensionForDisplay({ contentType, fileName });
return (
<StyledMessageHighlighter highlight={highlight} className="module-message__generic-attachment">
<StyledGenericAttachmentContainer
highlight={highlight}
selected={selected}
className={'module-message__generic-attachment'}
>
{pending ? (
<div className="module-message__generic-attachment__spinner-container">
<Spinner size="small" />
@ -208,7 +222,7 @@ export const MessageAttachment = (props: Props) => {
{fileSize}
</div>
</div>
</StyledMessageHighlighter>
</StyledGenericAttachmentContainer>
);
};
@ -216,6 +230,47 @@ function attachmentIsAttachmentTypeWithPath(attac: any): attac is AttachmentType
return attac.path !== undefined;
}
export async function showLightboxFromAttachmentProps(
messageId: string,
selected: AttachmentTypeWithPath | AttachmentType | PropsForAttachment
) {
const found = await Data.getMessageById(messageId);
if (!found) {
window.log.warn(`showLightboxFromAttachmentProps Message not found ${messageId}}`);
return;
}
const msgAttachments = found.getPropsForMessage().attachments;
let index = -1;
const media = (msgAttachments || []).map(attachmentForMedia => {
index++;
const messageTimestamp =
found.get('timestamp') || found.get('serverTimestamp') || found.get('received_at');
return {
index: clone(index),
objectURL: attachmentForMedia.url || undefined,
contentType: attachmentForMedia.contentType,
attachment: attachmentForMedia,
messageSender: found.getSource(),
messageTimestamp,
messageId,
};
});
if (attachmentIsAttachmentTypeWithPath(selected)) {
const lightBoxOptions: LightBoxOptions = {
media: media as any,
attachment: selected,
};
window.inboxStore?.dispatch(showLightBox(lightBoxOptions));
} else {
window.log.warn('Attachment is not of the right type');
}
}
const onClickAttachment = async (onClickProps: {
attachment: AttachmentTypeWithPath | AttachmentType;
messageId: string;

@ -5,11 +5,12 @@ import {
useAuthorName,
useAuthorProfileName,
useFirstMessageOfSeries,
useHideAvatarInMsgList,
useMessageAuthor,
useMessageDirection,
} from '../../../../state/selectors';
import {
useSelectedIsGroup,
useSelectedIsGroupOrCommunity,
useSelectedIsPublic,
} from '../../../../state/selectors/selectedConversation';
import { Flex } from '../../../basic/Flex';
@ -19,19 +20,21 @@ type Props = {
messageId: string;
};
const StyledAuthorContainer = styled(Flex)`
const StyledAuthorContainer = styled(Flex)<{ hideAvatar: boolean }>`
color: var(--text-primary-color);
text-overflow: ellipsis;
margin-inline-start: ${props => (props.hideAvatar ? 0 : 'var(--width-avatar-group-msg-list)')};
`;
export const MessageAuthorText = (props: Props) => {
const isPublic = useSelectedIsPublic();
const isGroup = useSelectedIsGroup();
const isGroup = useSelectedIsGroupOrCommunity();
const authorProfileName = useAuthorProfileName(props.messageId);
const authorName = useAuthorName(props.messageId);
const sender = useMessageAuthor(props.messageId);
const direction = useMessageDirection(props.messageId);
const firstMessageOfSeries = useFirstMessageOfSeries(props.messageId);
const hideAvatar = useHideAvatarInMsgList(props.messageId);
if (!props.messageId || !sender || !direction) {
return null;
@ -46,7 +49,7 @@ export const MessageAuthorText = (props: Props) => {
const displayedPubkey = authorProfileName ? PubKey.shorten(sender) : sender;
return (
<StyledAuthorContainer container={true}>
<StyledAuthorContainer container={true} hideAvatar={hideAvatar}>
<ContactName
pubkey={displayedPubkey}
name={authorName}

@ -26,9 +26,11 @@ import { Avatar, AvatarSize, CrownIcon } from '../../../avatar/Avatar';
const StyledAvatar = styled.div`
position: relative;
margin-inline-end: 20px;
padding-bottom: 6px;
padding-inline-end: 4px;
margin-inline-end: 10px;
max-width: var(
--width-avatar-group-msg-list
); // enforcing this so we change the variable when changing the content of the avatar
overflow-y: hidden;
`;
export type MessageAvatarSelectorProps = Pick<
@ -36,10 +38,10 @@ export type MessageAvatarSelectorProps = Pick<
'sender' | 'isSenderAdmin' | 'lastMessageOfSeries'
>;
type Props = { messageId: string; hideAvatar: boolean; isPrivate: boolean };
type Props = { messageId: string; hideAvatar: boolean; isPrivate: boolean; isDetailView?: boolean };
export const MessageAvatar = (props: Props) => {
const { messageId, hideAvatar, isPrivate } = props;
const { messageId, hideAvatar, isPrivate, isDetailView } = props;
const dispatch = useDispatch();
const selectedConvoKey = useSelectedConversationKey();
@ -129,19 +131,19 @@ export const MessageAvatar = (props: Props) => {
}
if (!lastMessageOfSeries) {
return <div style={{ marginInlineEnd: '60px' }} key={`msg-avatar-${sender}`} />;
return <div style={{ marginInlineEnd: 'var(--width-avatar-group-msg-list)' }} />;
}
/* eslint-disable @typescript-eslint/no-misused-promises */
// The styledAvatar, when rendered needs to have a width with margins included of var(--width-avatar-group-msg-list).
// This is so that the other message is still aligned when the avatar is not rendered (we need to make up for the space used by the avatar, and we use a margin of width-avatar-group-msg-list)
return (
<StyledAvatar
key={`msg-avatar-${sender}`}
style={{
visibility: hideAvatar ? 'hidden' : undefined,
}}
>
<Avatar size={AvatarSize.S} onAvatarClick={onMessageAvatarClick} pubkey={sender} />
{isSenderAdmin && <CrownIcon />}
{!isDetailView && isSenderAdmin ? <CrownIcon /> : null}
</StyledAvatar>
);
};

@ -4,16 +4,25 @@ 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, keyframes } from 'styled-components';
import styled from 'styled-components';
import { MessageModelType, MessageRenderingProps } from '../../../../models/messageType';
import { useMessageIsDeleted } from '../../../../state/selectors';
import { StateType } from '../../../../state/reducer';
import {
useHideAvatarInMsgList,
useMessageIsDeleted,
useMessageSelected,
} from '../../../../state/selectors';
import {
getMessageContentSelectorProps,
getQuotedMessageToAnimate,
getShouldHighlightMessage,
} from '../../../../state/selectors/conversations';
import { useSelectedIsPrivate } from '../../../../state/selectors/selectedConversation';
import { canDisplayImage } from '../../../../types/Attachment';
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
import { MessageAttachment } from './MessageAttachment';
import { MessageAvatar } from './MessageAvatar';
import { MessageHighlighter } from './MessageHighlighter';
import { MessageLinkPreview } from './MessageLinkPreview';
import { MessageQuote } from './MessageQuote';
import { MessageText } from './MessageText';
@ -45,70 +54,51 @@ function onClickOnMessageInnerContainer(event: React.MouseEvent<HTMLDivElement>)
}
}
const StyledMessageContent = styled.div``;
const opacityAnimation = keyframes`
0% {
opacity: 1;
}
25% {
opacity: 0.2;
}
50% {
opacity: 1;
}
75% {
opacity: 0.2;
}
100% {
opacity: 1;
}
const StyledMessageContent = styled.div<{ msgDirection: MessageModelType }>`
display: flex;
align-self: ${props => (props.msgDirection === 'incoming' ? 'flex-start' : 'flex-end')};
`;
export const StyledMessageHighlighter = styled.div<{
highlight: boolean;
}>`
${props =>
props.highlight &&
css`
animation: ${opacityAnimation} 1s linear;
`}
`;
const StyledMessageOpaqueContent = styled(StyledMessageHighlighter)<{
messageDirection: MessageModelType;
const StyledMessageOpaqueContent = styled(MessageHighlighter)<{
isIncoming: boolean;
highlight: boolean;
selected: boolean;
}>`
background: ${props =>
props.messageDirection === 'incoming'
props.isIncoming
? 'var(--message-bubbles-received-background-color)'
: 'var(--message-bubbles-sent-background-color)'};
align-self: ${props => (props.messageDirection === 'incoming' ? 'flex-start' : 'flex-end')};
align-self: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
padding: var(--padding-message-content);
border-radius: var(--border-radius-message-box);
width: 100%;
${props => props.selected && `box-shadow: var(--drop-shadow);`}
`;
export const IsMessageVisibleContext = createContext(false);
const StyledAvatarContainer = styled.div`
align-self: flex-end;
`;
export const MessageContent = (props: Props) => {
const [highlight, setHighlight] = useState(false);
const [didScroll, setDidScroll] = useState(false);
const contentProps = useSelector(state =>
getMessageContentSelectorProps(state as any, props.messageId)
const contentProps = useSelector((state: StateType) =>
getMessageContentSelectorProps(state, props.messageId)
);
const isDeleted = useMessageIsDeleted(props.messageId);
const [isMessageVisible, setMessageIsVisible] = useState(false);
const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
const selectedIsPrivate = useSelectedIsPrivate();
const hideAvatar = useHideAvatarInMsgList(props.messageId);
const [imageBroken, setImageBroken] = useState(false);
const onVisible = (inView: boolean | object) => {
if (
inView === true ||
((inView as any).type === 'focus' && (inView as any).returnValue === true)
) {
const onVisible = (inView: boolean, _: IntersectionObserverEntry) => {
if (inView) {
if (isMessageVisible !== true) {
setMessageIsVisible(true);
}
@ -122,6 +112,7 @@ export const MessageContent = (props: Props) => {
const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate);
const shouldHighlightMessage = useSelector(getShouldHighlightMessage);
const isQuotedMessageToAnimate = quotedMessageToAnimate === props.messageId;
const selected = useMessageSelected(props.messageId);
useLayoutEffect(() => {
if (isQuotedMessageToAnimate) {
@ -155,21 +146,43 @@ export const MessageContent = (props: Props) => {
return null;
}
const { direction, text, timestamp, serverTimestamp, previews, quote } = contentProps;
const {
direction,
text,
timestamp,
serverTimestamp,
previews,
quote,
attachments,
} = contentProps;
const hasContentBeforeAttachment = !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text);
const toolTipTitle = moment(serverTimestamp || timestamp).format('llll');
const isDetailViewAndSupportsAttachmentCarousel =
props.isDetailView && canDisplayImage(attachments);
return (
<StyledMessageContent
className={classNames('module-message__container', `module-message__container--${direction}`)}
role="button"
onClick={onClickOnMessageInnerContainer}
title={toolTipTitle}
msgDirection={direction}
>
<StyledAvatarContainer>
<MessageAvatar
messageId={props.messageId}
hideAvatar={hideAvatar}
isPrivate={selectedIsPrivate}
isDetailView={props.isDetailView}
/>
</StyledAvatarContainer>
<InView
id={`inview-content-${props.messageId}`}
as={'div'}
onChange={onVisible}
threshold={0}
rootMargin="500px 0px 500px 0px"
@ -182,7 +195,11 @@ export const MessageContent = (props: Props) => {
>
<IsMessageVisibleContext.Provider value={isMessageVisible}>
{hasContentBeforeAttachment && (
<StyledMessageOpaqueContent messageDirection={direction} highlight={highlight}>
<StyledMessageOpaqueContent
isIncoming={direction === 'incoming'}
highlight={highlight}
selected={selected}
>
{!isDeleted && (
<>
<MessageQuote messageId={props.messageId} />
@ -195,7 +212,7 @@ export const MessageContent = (props: Props) => {
<MessageText messageId={props.messageId} />
</StyledMessageOpaqueContent>
)}
{!isDeleted && (
{!isDeleted && isDetailViewAndSupportsAttachmentCarousel && !imageBroken ? null : (
<MessageAttachment
messageId={props.messageId}
imageBroken={imageBroken}

@ -6,19 +6,22 @@ import { replyToMessage } from '../../../../interactions/conversationInteraction
import { MessageRenderingProps } from '../../../../models/messageType';
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
import { updateReactListModal } from '../../../../state/ducks/modalDialog';
import { StateType } from '../../../../state/reducer';
import { useHideAvatarInMsgList } from '../../../../state/selectors';
import {
getMessageContentWithStatusesSelectorProps,
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { Reactions } from '../../../../util/reactions';
import { MessageAvatar } from './MessageAvatar';
import { Flex } from '../../../basic/Flex';
import { ExpirableReadableMessage } from '../message-item/ExpirableReadableMessage';
import { MessageAuthorText } from './MessageAuthorText';
import { MessageContent } from './MessageContent';
import { MessageContextMenu } from './MessageContextMenu';
import { MessageReactions, StyledMessageReactions } from './MessageReactions';
import { MessageReactions } from './MessageReactions';
import { MessageStatus } from './MessageStatus';
export type MessageContentWithStatusSelectorProps = Pick<
export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick<
MessageRenderingProps,
'conversationType' | 'direction' | 'isDeleted'
>;
@ -27,34 +30,34 @@ type Props = {
messageId: string;
ctxMenuID: string;
isDetailView?: boolean;
dataTestId?: string;
dataTestId: string;
enableReactions: boolean;
};
const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }>`
const StyledMessageContentContainer = styled.div<{ isIncoming: boolean }>`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: ${props => (props.direction === 'left' ? 'flex-start' : 'flex-end')};
align-items: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
padding-left: ${props => (props.isIncoming ? 0 : '25%')};
padding-right: ${props => (props.isIncoming ? '25%' : 0)};
width: 100%;
${StyledMessageReactions} {
margin-right: var(--margins-md);
}
margin-right: var(--margins-md);
`;
const StyledMessageWithAuthor = styled.div<{ isIncoming: boolean }>`
max-width: ${props => (props.isIncoming ? '100%' : 'calc(100% - 17px)')};
const StyledMessageWithAuthor = styled.div`
max-width: '100%';
display: flex;
flex-direction: column;
min-width: 0;
`;
export const MessageContentWithStatuses = (props: Props) => {
const contentProps = useSelector(state =>
getMessageContentWithStatusesSelectorProps(state as any, props.messageId)
const contentProps = useSelector((state: StateType) =>
getMessageContentWithStatusesSelectorProps(state, props.messageId)
);
const dispatch = useDispatch();
const hideAvatar = useHideAvatarInMsgList(props.messageId);
const multiSelectMode = useSelector(isMessageSelectionMode);
@ -88,55 +91,58 @@ export const MessageContentWithStatuses = (props: Props) => {
}
};
const { messageId, ctxMenuID, isDetailView, dataTestId, enableReactions } = props;
const { messageId, ctxMenuID, isDetailView = false, dataTestId, enableReactions } = props;
const [popupReaction, setPopupReaction] = useState('');
if (!contentProps) {
return null;
}
const { conversationType, direction, isDeleted } = contentProps;
const isIncoming = direction === 'incoming';
const isPrivate = conversationType === 'private';
const hideAvatar = isPrivate || direction === 'outgoing';
const { direction: _direction, isDeleted } = contentProps;
// NOTE we want messages on the left in the message detail view regardless of direction
const direction = isDetailView ? 'incoming' : _direction;
const isIncoming = direction === 'incoming';
const handleMessageReaction = async (emoji: string) => {
await Reactions.sendMessageReaction(messageId, emoji);
};
const handlePopupClick = () => {
dispatch(updateReactListModal({ reaction: popupReaction, messageId }));
dispatch(
updateReactListModal({
reaction: popupReaction,
messageId,
})
);
};
return (
<StyledMessageContentContainer
direction={isIncoming ? 'left' : 'right'}
isIncoming={isIncoming}
onMouseLeave={() => {
setPopupReaction('');
}}
>
<div
<ExpirableReadableMessage
messageId={messageId}
className={classNames('module-message', `module-message--${direction}`)}
role="button"
role={'button'}
isDetailView={isDetailView}
onClick={onClickOnMessageOuterContainer}
onDoubleClickCapture={onDoubleClickReplyToMessage}
data-testid={dataTestId}
dataTestId={dataTestId}
>
<MessageAvatar messageId={messageId} hideAvatar={hideAvatar} isPrivate={isPrivate} />
<MessageStatus
dataTestId="msg-status-incoming"
messageId={messageId}
isCorrectSide={isIncoming}
/>
<StyledMessageWithAuthor isIncoming={isIncoming}>
<MessageAuthorText messageId={messageId} />
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</StyledMessageWithAuthor>
<MessageStatus
dataTestId="msg-status-outgoing"
messageId={messageId}
isCorrectSide={!isIncoming}
/>
<Flex container={true} flexDirection="column" flexShrink={0} alignItems="flex-end">
<StyledMessageWithAuthor>
{!isDetailView && <MessageAuthorText messageId={messageId} />}
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</StyledMessageWithAuthor>
<MessageStatus
dataTestId="msg-status"
messageId={messageId}
isDetailView={isDetailView}
/>
</Flex>
{!isDeleted && (
<MessageContextMenu
messageId={messageId}
@ -144,8 +150,8 @@ export const MessageContentWithStatuses = (props: Props) => {
enableReactions={enableReactions}
/>
)}
</div>
{enableReactions && (
</ExpirableReadableMessage>
{!isDetailView && enableReactions ? (
<MessageReactions
messageId={messageId}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
@ -154,8 +160,9 @@ export const MessageContentWithStatuses = (props: Props) => {
setPopupReaction={setPopupReaction}
onPopupClick={handlePopupClick}
noAvatar={hideAvatar}
isDetailView={isDetailView}
/>
)}
) : null}
</StyledMessageContentContainer>
);
};

@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
import { isNumber } from 'lodash';
import { Item, ItemParams, Menu, useContextMenu } from 'react-contexify';
import { useDispatch } from 'react-redux';
import { useClickAway, useMouse } from 'react-use';
import styled from 'styled-components';
import { isNumber } from 'lodash';
import { Data } from '../../../../data/data';
import { MessageInteraction } from '../../../../interactions';
@ -21,9 +21,11 @@ import {
import { MessageRenderingProps } from '../../../../models/messageType';
import { pushUnblockToSend } from '../../../../session/utils/Toast';
import {
showMessageDetailsView,
openRightPanel,
showMessageInfoView,
toggleSelectedMessageId,
} from '../../../../state/ducks/conversations';
import { setRightOverlayMode } from '../../../../state/ducks/section';
import {
useMessageAttachments,
useMessageBody,
@ -86,19 +88,29 @@ const StyledEmojiPanelContainer = styled.div<{ x: number; y: number }>`
}
`;
const DeleteForEveryone = ({ messageId }: { messageId: string }) => {
const DeleteItem = ({ messageId }: { messageId: string }) => {
const convoId = useSelectedConversationKey();
const isPublic = useSelectedIsPublic();
const isDeletable = useMessageIsDeletable(messageId);
const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId);
if (!convoId || !isDeletableForEveryone) {
const onDelete = useCallback(() => {
if (convoId) {
if (!isPublic && isDeletable) {
void deleteMessagesById([messageId], convoId);
}
if (isPublic && isDeletableForEveryone) {
void deleteMessagesByIdForEveryone([messageId], convoId);
}
}
}, [convoId, isDeletable, isDeletableForEveryone, isPublic, messageId]);
if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) {
return null;
}
const onDeleteForEveryone = () => {
void deleteMessagesByIdForEveryone([messageId], convoId);
};
const unsendMessageText = window.i18n('deleteForEveryone');
return <Item onClick={onDeleteForEveryone}>{unsendMessageText}</Item>;
return <Item onClick={onDelete}>{window.i18n('delete')}</Item>;
};
type MessageId = { messageId: string };
@ -162,6 +174,28 @@ const RetryItem = ({ messageId }: MessageId) => {
return showRetry ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null;
};
export const showMessageInfoOverlay = async ({
messageId,
dispatch,
}: {
messageId: string;
dispatch: Dispatch<any>;
}) => {
const found = await Data.getMessageById(messageId);
if (found) {
dispatch(showMessageInfoView(messageId));
dispatch(
setRightOverlayMode({
type: 'message_info',
params: { messageId, visibleAttachmentIndex: 0 },
})
);
dispatch(openRightPanel());
} else {
window.log.warn(`[showMessageInfoOverlay] Message ${messageId} not found in db`);
}
};
export const MessageContextMenu = (props: Props) => {
const { messageId, contextMenuId, enableReactions } = props;
const dispatch = useDispatch();
@ -169,7 +203,6 @@ export const MessageContextMenu = (props: Props) => {
const isSelectedBlocked = useSelectedIsBlocked();
const convoId = useSelectedConversationKey();
const isPublic = useSelectedIsPublic();
const direction = useMessageDirection(messageId);
const status = useMessageStatus(messageId);
@ -178,7 +211,6 @@ export const MessageContextMenu = (props: Props) => {
const attachments = useMessageAttachments(messageId);
const timestamp = useMessageTimestamp(messageId);
const serverTimestamp = useMessageServerTimestamp(messageId);
const sender = useMessageSender(messageId);
const isOutgoing = direction === 'outgoing';
@ -214,18 +246,7 @@ export const MessageContextMenu = (props: Props) => {
[showEmojiPanel]
);
const onShowDetail = async () => {
const found = await Data.getMessageById(messageId);
if (found) {
const messageDetailsProps = await found.getPropsForMessageDetail();
dispatch(showMessageDetailsView(messageDetailsProps));
} else {
window.log.warn(`Message ${messageId} not found in db`);
}
};
const selectMessageText = window.i18n('selectMessage');
const deleteMessageJustForMeText = window.i18n('deleteJustForMe');
const onReply = useCallback(() => {
if (isSelectedBlocked) {
@ -243,12 +264,6 @@ export const MessageContextMenu = (props: Props) => {
dispatch(toggleSelectedMessageId(messageId));
}, [dispatch, messageId]);
const onDelete = useCallback(() => {
if (convoId) {
void deleteMessagesById([messageId], convoId);
}
}, [convoId, messageId]);
const onShowEmoji = () => {
hideAll();
setMouseX(docX);
@ -353,7 +368,11 @@ export const MessageContextMenu = (props: Props) => {
<Menu id={contextMenuId} onVisibilityChange={onVisibilityChange} animation="fade">
{enableReactions && (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<MessageReactBar action={onEmojiClick} additionalAction={onShowEmoji} />
<MessageReactBar
action={onEmojiClick}
additionalAction={onShowEmoji}
messageId={messageId}
/>
)}
{attachments?.length ? (
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
@ -362,15 +381,16 @@ export const MessageContextMenu = (props: Props) => {
{(isSent || !isOutgoing) && (
<Item onClick={onReply}>{window.i18n('replyToMessage')}</Item>
)}
{(!isPublic || isOutgoing) && (
<Item onClick={onShowDetail}>{window.i18n('moreInformation')}</Item>
)}
<Item
onClick={() => {
void showMessageInfoOverlay({ messageId, dispatch });
}}
>
{window.i18n('moreInformation')}
</Item>
<RetryItem messageId={messageId} />
{isDeletable ? <Item onClick={onSelect}>{selectMessageText}</Item> : null}
{isDeletable && !isPublic ? (
<Item onClick={onDelete}>{deleteMessageJustForMeText}</Item>
) : null}
<DeleteForEveryone messageId={messageId} />
<DeleteItem messageId={messageId} />
<AdminActionItems messageId={messageId} />
</Menu>
</SessionContextMenuContainer>

@ -0,0 +1,29 @@
import styled, { css, keyframes } from 'styled-components';
const opacityAnimation = keyframes`
0% {
opacity: 1;
}
25% {
opacity: 0.2;
}
50% {
opacity: 1;
}
75% {
opacity: 0.2;
}
100% {
opacity: 1;
}
`;
export const MessageHighlighter = styled.div<{
highlight: boolean;
}>`
${props =>
props.highlight &&
css`
animation: ${opacityAnimation} 1s linear;
`}
`;

@ -7,10 +7,7 @@ import { ToastUtils } from '../../../../session/utils';
import { openConversationToSpecificMessage } from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { useMessageDirection } from '../../../../state/selectors';
import {
getMessageQuoteProps,
isMessageDetailView,
} from '../../../../state/selectors/conversations';
import { getMessageQuoteProps } from '../../../../state/selectors/conversations';
import { Quote } from './quote/Quote';
type Props = {
@ -22,7 +19,6 @@ export type MessageQuoteSelectorProps = Pick<MessageRenderingProps, 'quote' | 'd
export const MessageQuote = (props: Props) => {
const selected = useSelector((state: StateType) => getMessageQuoteProps(state, props.messageId));
const direction = useMessageDirection(props.messageId);
const isMessageDetailViewMode = useSelector(isMessageDetailView);
if (!selected || isEmpty(selected)) {
return null;
@ -48,11 +44,6 @@ export const MessageQuote = (props: Props) => {
return;
}
if (isMessageDetailViewMode) {
// trying to scroll while in the container while the message detail view is shown has unknown effects
return;
}
let conversationKey = String(quote.convoId);
let messageIdToNavigateTo = String(quote.id);
let quoteNotFoundInDB = false;

@ -1,16 +1,21 @@
import { isEqual } from 'lodash';
import React, { ReactElement, useState } from 'react';
import useMount from 'react-use/lib/useMount';
import React, { ReactElement } from 'react';
import styled from 'styled-components';
import { RecentReactions } from '../../../../types/Reaction';
import { isEmpty } from 'lodash';
import moment from 'moment';
import useBoolean from 'react-use/lib/useBoolean';
import useInterval from 'react-use/lib/useInterval';
import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector';
import { DURATION } from '../../../../session/constants';
import { nativeEmojiData } from '../../../../util/emoji';
import { getRecentReactions } from '../../../../util/storage';
import { SessionIconButton } from '../../../icon';
import { SpacerSM } from '../../../basic/Text';
import { SessionIcon, SessionIconButton } from '../../../icon';
type Props = {
action: (...args: Array<any>) => void;
additionalAction: (...args: Array<any>) => void;
messageId: string;
};
const StyledMessageReactBar = styled.div`
@ -18,8 +23,6 @@ const StyledMessageReactBar = styled.div`
border-radius: 25px;
box-shadow: 0 2px 16px 0 rgba(0, 0, 0, 0.2), 0 0px 20px 0 rgba(0, 0, 0, 0.19);
position: absolute;
top: -56px;
padding: 4px 8px;
white-space: nowrap;
width: 302px;
@ -51,45 +54,143 @@ const ReactButton = styled.span`
}
`;
export const MessageReactBar = (props: Props): ReactElement => {
const { action, additionalAction } = props;
const [recentReactions, setRecentReactions] = useState<RecentReactions>();
const StyledContainer = styled.div<{ expirationTimestamp: number | null }>`
position: absolute;
top: ${props => (props.expirationTimestamp ? '-106px' : '-56px')};
display: flex;
flex-direction: column;
min-width: 0;
align-items: flex-start;
left: -1px;
`;
useMount(() => {
const reactions = new RecentReactions(getRecentReactions());
if (reactions && !isEqual(reactions, recentReactions)) {
setRecentReactions(reactions);
}
});
const StyledExpiresIn = styled.div`
border-radius: 8px;
padding: 10px;
white-space: nowrap;
color: var(--text-primary-color);
size: var(--font-size-sm);
background-color: var(--context-menu-background-color);
box-shadow: 0px 0px 9px 0px var(--context-menu-shadow-color);
margin-top: 7px;
display: flex;
align-items: center;
min-width: 0;
`;
function useIsRenderedExpiresInItem(messageId: string) {
const expiryDetails = useMessageExpirationPropsById(messageId);
if (
!expiryDetails ||
isEmpty(expiryDetails) ||
!expiryDetails.expirationDurationMs ||
expiryDetails.isExpired ||
!expiryDetails.expirationTimestamp
) {
return null;
}
return expiryDetails.expirationTimestamp;
}
function formatTimeLeft({ timeLeftMs }: { timeLeftMs: number }) {
const timeLeft = moment(timeLeftMs).utc();
if (timeLeftMs <= 0) {
return `0s`;
}
if (timeLeft.isBefore(moment.utc(0).add(1, 'minute'))) {
return window.i18n('messageWillDisappear', [`${timeLeft.seconds()}s`]);
}
if (timeLeft.isBefore(moment.utc(0).add(1, 'hour'))) {
const extraUnit = timeLeft.seconds() ? ` ${timeLeft.seconds()}s` : '';
return window.i18n('messageWillDisappear', [`${timeLeft.minutes()}m${extraUnit}`]);
}
if (timeLeft.isBefore(moment.utc(0).add(1, 'day'))) {
const extraUnit = timeLeft.minutes() ? ` ${timeLeft.minutes()}m` : '';
return window.i18n('messageWillDisappear', [`${timeLeft.hours()}h${extraUnit}`]);
}
if (!recentReactions) {
return <></>;
if (timeLeft.isBefore(moment.utc(0).add(7, 'day'))) {
const extraUnit = timeLeft.hours() ? ` ${timeLeft.hours()}h` : '';
return window.i18n('messageWillDisappear', [`${timeLeft.dayOfYear() - 1}d${extraUnit}`]);
}
if (timeLeft.isBefore(moment.utc(0).add(31, 'day'))) {
const days = timeLeft.dayOfYear() - 1;
const weeks = Math.floor(days / 7);
const daysLeft = days % 7;
const extraUnit = daysLeft ? ` ${daysLeft}d` : '';
return window.i18n('messageWillDisappear', [`${weeks}w${extraUnit}`]);
}
return '...';
}
const ExpiresInItem = ({ expirationTimestamp }: { expirationTimestamp?: number | null }) => {
// this boolean is just used to forceRefresh the state when we get to display seconds in the contextmenu
const [refresh, setRefresh] = useBoolean(false);
const timeLeftMs = (expirationTimestamp || 0) - Date.now();
useInterval(
() => {
setRefresh(!refresh);
},
// We want to force refresh this component a lot more if the message has less than 1h before disappearing,
// because when that's the case we also display the seconds left (i.e. 59min 23s) and we want that 23s to be dynamic.
// Also, we use a refresh interval of 500 rather than 1s so that the counter is a bit smoother
timeLeftMs > 0 && timeLeftMs <= 1 * DURATION.HOURS ? 500 : null
);
if (!expirationTimestamp || timeLeftMs < 0) {
return null;
}
return (
<StyledExpiresIn>
<SessionIcon iconSize={'small'} iconType="timerFixed" />
<SpacerSM />
<span>{formatTimeLeft({ timeLeftMs })}</span>
</StyledExpiresIn>
);
};
export const MessageReactBar = ({ action, additionalAction, messageId }: Props): ReactElement => {
const recentReactions = getRecentReactions();
const expirationTimestamp = useIsRenderedExpiresInItem(messageId);
return (
<StyledMessageReactBar>
{recentReactions &&
recentReactions.items.map(emoji => (
<ReactButton
key={emoji}
role={'img'}
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
onClick={() => {
action(emoji);
}}
>
{emoji}
</ReactButton>
))}
<SessionIconButton
iconColor={'var(--emoji-reaction-bar-icon-color)'}
iconPadding={'8px'}
iconSize={'huge'}
iconType="plusThin"
backgroundColor={'var(--emoji-reaction-bar-icon-background-color)'}
borderRadius="300px"
onClick={additionalAction}
/>
</StyledMessageReactBar>
<StyledContainer expirationTimestamp={expirationTimestamp}>
<StyledMessageReactBar>
{recentReactions &&
recentReactions.map(emoji => (
<ReactButton
key={emoji}
role={'img'}
aria-label={
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined
}
onClick={() => {
action(emoji);
}}
>
{emoji}
</ReactButton>
))}
<SessionIconButton
iconColor={'var(--emoji-reaction-bar-icon-color)'}
iconPadding={'8px'}
iconSize={'huge'}
iconType="plusThin"
backgroundColor={'var(--emoji-reaction-bar-icon-background-color)'}
borderRadius="300px"
onClick={additionalAction}
/>
</StyledMessageReactBar>
<ExpiresInItem expirationTimestamp={expirationTimestamp} />
</StyledContainer>
);
};

@ -3,7 +3,8 @@ import React, { ReactElement, useEffect, useState } from 'react';
import styled from 'styled-components';
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
import { MessageRenderingProps } from '../../../../models/messageType';
import { useSelectedIsGroup } from '../../../../state/selectors/selectedConversation';
import { REACT_LIMIT } from '../../../../session/constants';
import { useSelectedIsGroupOrCommunity } from '../../../../state/selectors/selectedConversation';
import { SortedReactionList } from '../../../../types/Reaction';
import { nativeEmojiData } from '../../../../util/emoji';
import { Flex } from '../../../basic/Flex';
@ -146,9 +147,10 @@ type Props = {
inModal?: boolean;
onSelected?: (emoji: string) => boolean;
noAvatar: boolean;
isDetailView?: boolean;
};
export const MessageReactions = (props: Props): ReactElement => {
export const MessageReactions = (props: Props) => {
const {
messageId,
hasReactLimit = true,
@ -159,6 +161,7 @@ export const MessageReactions = (props: Props): ReactElement => {
inModal = false,
onSelected,
noAvatar,
isDetailView,
} = props;
const [reactions, setReactions] = useState<SortedReactionList>([]);
@ -172,7 +175,7 @@ export const MessageReactions = (props: Props): ReactElement => {
const msgProps = useMessageReactsPropsById(messageId);
const inGroup = useSelectedIsGroup();
const inGroup = useSelectedIsGroupOrCommunity();
useEffect(() => {
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
@ -185,12 +188,14 @@ export const MessageReactions = (props: Props): ReactElement => {
}, [msgProps?.sortedReacts, reactions]);
if (!msgProps) {
return <></>;
return null;
}
const { sortedReacts } = msgProps;
const reactLimit = 6;
if (!sortedReacts || !sortedReacts.length) {
return null;
}
const reactionsProps = {
messageId,
@ -199,10 +204,10 @@ export const MessageReactions = (props: Props): ReactElement => {
inGroup,
handlePopupX: setPopupX,
handlePopupY: setPopupY,
onClick,
onClick: !isDetailView ? onClick : undefined,
popupReaction,
onSelected,
handlePopupReaction: setPopupReaction,
handlePopupReaction: !isDetailView ? setPopupReaction : undefined,
handlePopupClick: onPopupClick,
};
@ -220,7 +225,7 @@ export const MessageReactions = (props: Props): ReactElement => {
>
{sortedReacts &&
sortedReacts?.length !== 0 &&
(!hasReactLimit || sortedReacts.length <= reactLimit ? (
(!hasReactLimit || sortedReacts.length <= REACT_LIMIT ? (
<Reactions {...reactionsProps} />
) : (
<ExtendedReactions handleExpand={handleExpand} {...reactionsProps} />

@ -1,34 +1,249 @@
import React from 'react';
import { MessageRenderingProps } from '../../../../models/messageType';
import { OutgoingMessageStatus } from './OutgoingMessageStatus';
import { useMessageDirection, useMessageStatus } from '../../../../state/selectors';
import { ipcRenderer } from 'electron';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector';
import { useMessageStatus } from '../../../../state/selectors';
import { getMostRecentMessageId } from '../../../../state/selectors/conversations';
import { useSelectedIsGroupOrCommunity } from '../../../../state/selectors/selectedConversation';
import { SpacerXS } from '../../../basic/Text';
import { SessionIcon, SessionIconType } from '../../../icon';
import { ExpireTimer } from '../../ExpireTimer';
type Props = {
isCorrectSide: boolean;
isDetailView: boolean;
messageId: string;
dataTestId?: string;
dataTestId?: string | undefined;
};
/**
* MessageStatus is used to display the status of an outgoing OR incoming message.
* There are 3 parts to this status: a status text, a status icon and a expiring stopwatch.
* At all times, we either display `text + icon` OR `text + stopwatch`.
*
* The logic to display the text is :
* - if the message is expiring:
* - if the message is incoming: display its 'read' state and the stopwatch icon (1)
* - if the message is outgoing: display its status and the stopwatch, unless when the status is error or sending (just display icon and text in this case, no stopwatch) (2)
* - if the message is not expiring:
* - if the message is incoming: do not show anything (3)
* - if the message is outgoing: show the text for the last message, or a message sending, or in the error state. (4)
*/
export const MessageStatus = ({ isDetailView, messageId, dataTestId }: Props) => {
const status = useMessageStatus(messageId);
const selected = useMessageExpirationPropsById(messageId);
if (!messageId || !selected || isDetailView) {
return null;
}
const isIncoming = selected.direction === 'incoming';
if (isIncoming) {
if (selected.isUnread || !selected.expirationDurationMs || !selected.expirationTimestamp) {
return null; // incoming and not expiring, this is case (3) above
}
// incoming and expiring, this is case (1) above
return <MessageStatusRead dataTestId={dataTestId} messageId={messageId} isIncoming={true} />;
}
switch (status) {
case 'sending':
return <MessageStatusSending dataTestId={dataTestId} messageId={messageId} />; // we always show sending state
case 'sent':
return <MessageStatusSent dataTestId={dataTestId} messageId={messageId} />;
case 'read':
return <MessageStatusRead dataTestId={dataTestId} messageId={messageId} isIncoming={false} />; // read is used for both incoming and outgoing messages, but not with the same UI
case 'error':
return <MessageStatusError dataTestId={dataTestId} messageId={messageId} />; // we always show error state
default:
return null;
}
};
const MessageStatusContainer = styled.div<{ isIncoming: boolean; isGroup: boolean }>`
display: inline-block;
align-self: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
flex-direction: ${props =>
props.isIncoming
? 'row-reverse'
: 'row'}; // we want {icon}{text} for incoming read messages, but {text}{icon} for outgoing messages
margin-bottom: 2px;
margin-inline-start: 5px;
cursor: pointer;
display: flex;
align-items: center;
margin-inline-start: ${props =>
props.isGroup || !props.isIncoming ? 'var(--width-avatar-group-msg-list)' : 0};
`;
const StyledStatusText = styled.div<{ textColor: string }>`
font-size: small;
color: ${props => props.textColor};
`;
const TextDetails = ({ text, textColor }: { text: string; textColor: string }) => {
return (
<>
<StyledStatusText textColor={textColor}>{text}</StyledStatusText>
<SpacerXS />
</>
);
};
export type MessageStatusSelectorProps = Pick<MessageRenderingProps, 'direction' | 'status'>;
function IconDanger({ iconType }: { iconType: SessionIconType }) {
return <SessionIcon iconColor={'var(--danger-color'} iconType={iconType} iconSize="tiny" />;
}
function IconNormal({
iconType,
rotateDuration,
}: {
iconType: SessionIconType;
rotateDuration?: number | undefined;
}) {
return (
<SessionIcon
rotateDuration={rotateDuration}
iconColor={'var(--text-secondary-color)'}
iconType={iconType}
iconSize="tiny"
/>
);
}
export const MessageStatus = (props: Props) => {
const { isCorrectSide, dataTestId } = props;
const direction = useMessageDirection(props.messageId);
const status = useMessageStatus(props.messageId);
function useIsExpiring(messageId: string) {
const selected = useMessageExpirationPropsById(messageId);
return (
selected && selected.expirationDurationMs && selected.expirationTimestamp && !selected.isExpired
);
}
if (!props.messageId) {
function useIsMostRecentMessage(messageId: string) {
const mostRecentMessageId = useSelector(getMostRecentMessageId);
const isMostRecentMessage = mostRecentMessageId === messageId;
return isMostRecentMessage;
}
function MessageStatusExpireTimer(props: Pick<Props, 'messageId'>) {
const selected = useMessageExpirationPropsById(props.messageId);
if (
!selected ||
!selected.expirationDurationMs ||
!selected.expirationTimestamp ||
selected.isExpired
) {
return null;
}
return (
<ExpireTimer
expirationDurationMs={selected.expirationDurationMs}
expirationTimestamp={selected.expirationTimestamp}
/>
);
}
const MessageStatusSending = ({ dataTestId }: Omit<Props, 'isDetailView'>) => {
// while sending, we do not display the expire timer at all.
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="sending"
isIncoming={false}
isGroup={false}
>
<TextDetails text={window.i18n('sending')} textColor="var(--text-secondary-color)" />
<IconNormal rotateDuration={2} iconType="sending" />
</MessageStatusContainer>
);
};
/**
* Returns the correct expiring stopwatch icon if this message is expiring, or a normal status icon otherwise.
* Only to be used with the status "read" and "sent"
*/
function IconForExpiringMessageId({
messageId,
iconType,
}: Pick<Props, 'messageId'> & { iconType: SessionIconType }) {
const isExpiring = useIsExpiring(messageId);
return isExpiring ? (
<MessageStatusExpireTimer messageId={messageId} />
) : (
<IconNormal iconType={iconType} />
);
}
if (!isCorrectSide) {
const MessageStatusSent = ({ dataTestId, messageId }: Omit<Props, 'isDetailView'>) => {
const isExpiring = useIsExpiring(messageId);
const isMostRecentMessage = useIsMostRecentMessage(messageId);
const isGroup = useSelectedIsGroupOrCommunity();
// we hide a "sent" message status which is not expiring except for the most recent message
if (!isExpiring && !isMostRecentMessage) {
return null;
}
const isIncoming = direction === 'incoming';
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="sent"
isIncoming={false}
isGroup={isGroup}
>
<TextDetails text={window.i18n('sent')} textColor="var(--text-secondary-color)" />
<IconForExpiringMessageId messageId={messageId} iconType="circleCheck" />
</MessageStatusContainer>
);
};
const MessageStatusRead = ({
dataTestId,
messageId,
isIncoming,
}: Omit<Props, 'isDetailView'> & { isIncoming: boolean }) => {
const isExpiring = useIsExpiring(messageId);
const isGroup = useSelectedIsGroupOrCommunity();
const isMostRecentMessage = useIsMostRecentMessage(messageId);
const showStatus = !isIncoming && Boolean(status);
if (!showStatus) {
// we hide an outgoing "read" message status which is not expiring except for the most recent message
if (!isIncoming && !isExpiring && !isMostRecentMessage) {
return null;
}
return <OutgoingMessageStatus dataTestId={dataTestId} status={status} />;
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="read"
isIncoming={isIncoming}
isGroup={isGroup}
>
<TextDetails text={window.i18n('read')} textColor="var(--text-secondary-color)" />
<IconForExpiringMessageId messageId={messageId} iconType="doubleCheckCircleFilled" />
</MessageStatusContainer>
);
};
const MessageStatusError = ({ dataTestId }: Omit<Props, 'isDetailView'>) => {
const showDebugLog = useCallback(() => {
ipcRenderer.send('show-debug-log');
}, []);
// when on error, we do not display the expire timer at all.
const isGroup = useSelectedIsGroupOrCommunity();
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="failed"
onClick={showDebugLog}
title={window.i18n('sendFailed')}
isIncoming={false}
isGroup={isGroup}
>
<TextDetails text={window.i18n('failedToSendMessage')} textColor="var(--danger-color)" />
<IconDanger iconType="error" />
</MessageStatusContainer>
);
};

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

@ -2,9 +2,9 @@ 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';
import { useSelectedIsPublic } from '../../../../../state/selectors/selectedConversation';
const StyledQuoteAuthor = styled.div<{ isIncoming: boolean }>`
color: ${props =>

@ -1,7 +1,7 @@
import { isEmpty } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { useSelectedIsGroup } from '../../../../../state/selectors/selectedConversation';
import { useSelectedIsGroupOrCommunity } from '../../../../../state/selectors/selectedConversation';
import { MIME } from '../../../../../types';
import { GoogleChrome } from '../../../../../util';
import { MessageBody } from '../MessageBody';
@ -61,7 +61,7 @@ export const QuoteText = (
) => {
const { text, attachment, isIncoming, referencedMessageNotFound } = props;
const isGroup = useSelectedIsGroup();
const isGroup = useSelectedIsGroupOrCommunity();
if (!referencedMessageNotFound && attachment && !isEmpty(attachment)) {
const { contentType, isVoiceMessage } = attachment;

@ -1,13 +1,11 @@
import React from 'react';
import { PropsForDataExtractionNotification } from '../../../../models/messageType';
import { SignalService } from '../../../../protobuf';
import { Flex } from '../../../basic/Flex';
import { SpacerSM, Text } from '../../../basic/Text';
import { SessionIcon } from '../../../icon';
import { ReadableMessage } from './ReadableMessage';
import { ExpirableReadableMessage } from './ExpirableReadableMessage';
import { NotificationBubble } from './notification-bubble/NotificationBubble';
export const DataExtractionNotification = (props: PropsForDataExtractionNotification) => {
const { name, type, source, messageId, isUnread, receivedAt } = props;
const { name, type, source, messageId } = props;
let contentText: string;
if (type === SignalService.DataExtractionNotification.Type.MEDIA_SAVED) {
@ -17,24 +15,13 @@ export const DataExtractionNotification = (props: PropsForDataExtractionNotifica
}
return (
<ReadableMessage
<ExpirableReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
dataTestId="data-extraction-notification"
key={`readable-message-${messageId}`}
isControlMessage={true}
>
<Flex
container={true}
flexDirection="row"
alignItems="center"
justifyContent="center"
margin={'var(--margins-sm)'}
id={`msg-${messageId}`}
>
<SessionIcon iconType="upload" iconSize="small" iconRotation={180} />
<SpacerSM />
<Text text={contentText} subtle={true} ellipsisOverflow={true} />
</Flex>
</ReadableMessage>
<NotificationBubble notificationText={contentText} iconType="save" />
</ExpirableReadableMessage>
);
};

@ -0,0 +1,161 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useInterval, useMount } from 'react-use';
import styled from 'styled-components';
import { Data } from '../../../../data/data';
import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector';
import { MessageModelType } from '../../../../models/messageType';
import { getConversationController } from '../../../../session/conversations';
import { PropsForExpiringMessage, messagesExpired } from '../../../../state/ducks/conversations';
import { getIncrement } from '../../../../util/timer';
import { ExpireTimer } from '../../ExpireTimer';
import { ReadableMessage, ReadableMessageProps } from './ReadableMessage';
const EXPIRATION_CHECK_MINIMUM = 2000;
function useIsExpired(
props: Omit<PropsForExpiringMessage, 'messageId' | 'direction'> & {
messageId: string | undefined;
direction: MessageModelType | undefined;
}
) {
const {
convoId,
messageId,
expirationDurationMs,
expirationTimestamp,
isExpired: isExpiredProps,
} = props;
const dispatch = useDispatch();
const [isExpired] = useState(isExpiredProps);
const checkExpired = useCallback(async () => {
const now = Date.now();
if (!messageId || !expirationTimestamp || !expirationDurationMs) {
return;
}
if (isExpired || now >= expirationTimestamp) {
await Data.removeMessage(messageId);
if (convoId) {
dispatch(
messagesExpired([
{
conversationKey: convoId,
messageId,
},
])
);
const convo = getConversationController().get(convoId);
convo?.updateLastMessage();
}
}
}, [messageId, expirationTimestamp, expirationDurationMs, isExpired, convoId, dispatch]);
let checkFrequency: number | null = null;
if (expirationDurationMs) {
const increment = getIncrement(expirationDurationMs || EXPIRATION_CHECK_MINIMUM);
checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
}
useMount(() => {
void checkExpired();
}); // check on mount
useInterval(checkExpired, checkFrequency); // check every 2sec or sooner if needed
return { isExpired };
}
const StyledReadableMessage = styled(ReadableMessage)<{
isIncoming: boolean;
}>`
display: flex;
justify-content: flex-end; // ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
align-items: ${props => (props.isIncoming ? 'flex-start' : 'flex-end')};
width: 100%;
flex-direction: column;
`;
export interface ExpirableReadableMessageProps
extends Omit<ReadableMessageProps, 'receivedAt' | 'isUnread'> {
messageId: string;
isControlMessage?: boolean;
isDetailView?: boolean;
}
function ExpireTimerControlMessage({
expirationTimestamp,
expirationDurationMs,
isControlMessage,
}: {
expirationDurationMs: number | null | undefined;
expirationTimestamp: number | null | undefined;
isControlMessage: boolean | undefined;
}) {
if (!isControlMessage) {
return null;
}
return (
<ExpireTimer
expirationDurationMs={expirationDurationMs || undefined}
expirationTimestamp={expirationTimestamp}
/>
);
}
export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) => {
const selected = useMessageExpirationPropsById(props.messageId);
const { isControlMessage, onClick, onDoubleClickCapture, role, dataTestId } = props;
const { isExpired } = useIsExpired({
convoId: selected?.convoId,
messageId: selected?.messageId,
direction: selected?.direction,
expirationTimestamp: selected?.expirationTimestamp,
expirationDurationMs: selected?.expirationDurationMs,
isExpired: selected?.isExpired,
});
if (!selected || isExpired) {
return null;
}
const {
messageId,
direction: _direction,
receivedAt,
isUnread,
expirationDurationMs,
expirationTimestamp,
} = selected;
// NOTE we want messages on the left in the message detail view regardless of direction
const direction = props.isDetailView ? 'incoming' : _direction;
const isIncoming = direction === 'incoming';
return (
<StyledReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={!!isUnread}
isIncoming={isIncoming}
onClick={onClick}
onDoubleClickCapture={onDoubleClickCapture}
role={role}
key={`readable-message-${messageId}`}
dataTestId={dataTestId}
>
<ExpireTimerControlMessage
expirationDurationMs={expirationDurationMs}
expirationTimestamp={expirationTimestamp}
isControlMessage={isControlMessage}
/>
{props.children}
</StyledReadableMessage>
);
};

@ -1,29 +1,19 @@
import classNames from 'classnames';
import { isNil, isString, toNumber } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { contextMenu } from 'react-contexify';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import styled, { keyframes } from 'styled-components';
import useInterval from 'react-use/lib/useInterval';
import useMount from 'react-use/lib/useMount';
import { isNil, isString, toNumber } from 'lodash';
import { Data } from '../../../../data/data';
import { MessageRenderingProps } from '../../../../models/messageType';
import { getConversationController } from '../../../../session/conversations';
import { messagesExpired } from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { useMessageSelected } from '../../../../state/selectors';
import {
getGenericReadableMessageSelectorProps,
getIsMessageSelected,
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { getIncrement } from '../../../../util/timer';
import { ExpireTimer } from '../../ExpireTimer';
import { isOpenOrClosedGroup } from '../../../../models/conversationAttributes';
import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus';
import { StyledMessageReactionsContainer } from '../message-content/MessageReactions';
import { ReadableMessage } from './ReadableMessage';
export type GenericReadableMessageSelectorProps = Pick<
MessageRenderingProps,
@ -31,74 +21,11 @@ export type GenericReadableMessageSelectorProps = Pick<
| 'conversationType'
| 'receivedAt'
| 'isUnread'
| 'expirationLength'
| 'expirationTimestamp'
| 'isKickedFromGroup'
| 'isExpired'
| 'convoId'
| 'isDeleted'
>;
type ExpiringProps = {
isExpired?: boolean;
expirationTimestamp?: number | null;
expirationLength?: number | null;
convoId?: string;
messageId: string;
};
const EXPIRATION_CHECK_MINIMUM = 2000;
function useIsExpired(props: ExpiringProps) {
const {
convoId,
messageId,
expirationLength,
expirationTimestamp,
isExpired: isExpiredProps,
} = props;
const dispatch = useDispatch();
const [isExpired] = useState(isExpiredProps);
const checkExpired = useCallback(async () => {
const now = Date.now();
if (!expirationTimestamp || !expirationLength) {
return;
}
if (isExpired || now >= expirationTimestamp) {
await Data.removeMessage(messageId);
if (convoId) {
dispatch(
messagesExpired([
{
conversationKey: convoId,
messageId,
},
])
);
const convo = getConversationController().get(convoId);
convo?.updateLastMessage();
}
}
}, [dispatch, expirationTimestamp, expirationLength, isExpired, messageId, convoId]);
let checkFrequency: number | null = null;
if (expirationLength) {
const increment = getIncrement(expirationLength || EXPIRATION_CHECK_MINIMUM);
checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
}
useMount(() => {
void checkExpired();
});
useInterval(checkExpired, checkFrequency); // check every 2sec or sooner if needed
return { isExpired };
}
type Props = {
messageId: string;
ctxMenuID: string;
@ -111,15 +38,16 @@ const highlightedMessageAnimation = keyframes`
}
`;
const StyledReadableMessage = styled(ReadableMessage)<{
const StyledReadableMessage = styled.div<{
selected: boolean;
isRightClicked: boolean;
isDetailView?: boolean;
}>`
display: flex;
align-items: center;
width: 100%;
letter-spacing: 0.03rem;
padding: 0 var(--margins-lg) 0;
padding: ${props => (props.isDetailView ? '0' : 'var(--margins-xs) var(--margins-lg) 0')};
&.message-highlighted {
animation: ${highlightedMessageAnimation} 1s ease-in-out;
@ -130,22 +58,9 @@ const StyledReadableMessage = styled(ReadableMessage)<{
}
${props =>
!props.selected &&
props.isRightClicked &&
`
background-color: var(--conversation-tab-background-selected-color);
`}
${props =>
props.selected &&
`
&.message-selected {
.module-message {
&__container {
box-shadow: var(--drop-shadow);
}
}
}
`}
`background-color: var(--conversation-tab-background-selected-color);`}
`;
export const GenericReadableMessage = (props: Props) => {
@ -153,22 +68,12 @@ export const GenericReadableMessage = (props: Props) => {
const [enableReactions, setEnableReactions] = useState(true);
const msgProps = useSelector(state =>
getGenericReadableMessageSelectorProps(state as any, props.messageId)
const msgProps = useSelector((state: StateType) =>
getGenericReadableMessageSelectorProps(state, props.messageId)
);
const expiringProps: ExpiringProps = {
convoId: msgProps?.convoId,
expirationLength: msgProps?.expirationLength,
messageId: props.messageId,
expirationTimestamp: msgProps?.expirationTimestamp,
isExpired: msgProps?.isExpired,
};
const { isExpired } = useIsExpired(expiringProps);
const isMessageSelected = useMessageSelected(props.messageId);
const isMessageSelected = useSelector(state =>
getIsMessageSelected(state as any, props.messageId)
);
const multiSelectMode = useSelector(isMessageSelectionMode);
const [isRightClicked, setIsRightClicked] = useState(false);
@ -228,58 +133,25 @@ export const GenericReadableMessage = (props: Props) => {
if (!msgProps) {
return null;
}
const {
direction,
conversationType,
receivedAt,
isUnread,
expirationLength,
expirationTimestamp,
} = msgProps;
if (isExpired) {
return null;
}
const selected = isMessageSelected || false;
const isGroup = isOpenOrClosedGroup(conversationType);
const isIncoming = direction === 'incoming';
return (
<StyledReadableMessage
messageId={messageId}
selected={selected}
isDetailView={isDetailView}
isRightClicked={isRightClicked}
className={classNames(
selected && 'message-selected',
isGroup && 'public-chat-message-wrapper'
)}
className={classNames(selected ? 'message-selected' : undefined)}
onContextMenu={handleContextMenu}
receivedAt={receivedAt}
isUnread={!!isUnread}
key={`readable-message-${messageId}`}
>
{expirationLength && expirationTimestamp && (
<ExpireTimer
isCorrectSide={!isIncoming}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
/>
)}
<MessageContentWithStatuses
ctxMenuID={ctxMenuID}
messageId={messageId}
isDetailView={isDetailView}
dataTestId={`message-content-${messageId}`}
dataTestId={'message-content'}
enableReactions={enableReactions}
/>
{expirationLength && expirationTimestamp && (
<ExpireTimer
isCorrectSide={isIncoming}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
/>
)}
</StyledReadableMessage>
);
};

@ -5,7 +5,62 @@ import styled from 'styled-components';
import { acceptOpenGroupInvitation } from '../../../../interactions/messageInteractions';
import { PropsForGroupInvitation } from '../../../../state/ducks/conversations';
import { SessionIconButton } from '../../../icon';
import { ReadableMessage } from './ReadableMessage';
import { ExpirableReadableMessage } from './ExpirableReadableMessage';
const StyledGroupInvitation = styled.div`
background-color: var(--message-bubbles-received-background-color);
&.invitation-outgoing {
background-color: var(--message-bubbles-sent-background-color);
align-self: flex-end;
.contents {
.group-details {
color: var(--message-bubbles-sent-text-color);
}
.session-icon-button {
background-color: var(--transparent-color);
}
}
}
display: inline-block;
padding: 4px;
margin: var(--margins-xs) calc(var(--margins-lg) + var(--margins-md)) 0 var(--margins-lg);
border-radius: var(--border-radius-message-box);
align-self: flex-start;
box-shadow: none;
.contents {
display: flex;
align-items: center;
margin: 6px;
.invite-group-avatar {
height: 48px;
width: 48px;
}
.group-details {
display: inline-flex;
flex-direction: column;
color: var(--message-bubbles-received-text-color);
padding: 0px 12px;
.group-name {
font-weight: bold;
font-size: 18px;
}
}
.session-icon-button {
background-color: var(--primary-color);
}
}
`;
const StyledIconContainer = styled.div`
background-color: var(--message-link-preview-background-color);
@ -13,7 +68,7 @@ const StyledIconContainer = styled.div`
`;
export const GroupInvitation = (props: PropsForGroupInvitation) => {
const { messageId, receivedAt, isUnread } = props;
const { messageId } = props;
const classes = ['group-invitation'];
if (props.direction === 'outgoing') {
@ -22,37 +77,34 @@ export const GroupInvitation = (props: PropsForGroupInvitation) => {
const openGroupInvitation = window.i18n('openGroupInvitation');
return (
<ReadableMessage
<ExpirableReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
dataTestId="control-message"
>
<div className="group-invitation-container" id={`msg-${props.messageId}`}>
<div className={classNames(classes)}>
<div className="contents">
<StyledIconContainer>
<SessionIconButton
iconColor={
props.direction === 'outgoing'
? 'var(--message-bubbles-sent-text-color)'
: 'var(--message-bubbles-received-text-color)'
}
iconType={props.direction === 'outgoing' ? 'communities' : 'plus'}
iconSize={'large'}
onClick={() => {
acceptOpenGroupInvitation(props.acceptUrl, props.serverName);
}}
/>
</StyledIconContainer>
<span className="group-details">
<span className="group-name">{props.serverName}</span>
<span className="group-type">{openGroupInvitation}</span>
<span className="group-address">{props.url}</span>
</span>
</div>
<StyledGroupInvitation className={classNames(classes)}>
<div className="contents">
<StyledIconContainer>
<SessionIconButton
iconColor={
props.direction === 'outgoing'
? 'var(--message-bubbles-sent-text-color)'
: 'var(--message-bubbles-received-text-color)'
}
iconType={props.direction === 'outgoing' ? 'communities' : 'plus'}
iconSize={'large'}
onClick={() => {
acceptOpenGroupInvitation(props.acceptUrl, props.serverName);
}}
/>
</StyledIconContainer>
<span className="group-details">
<span className="group-name">{props.serverName}</span>
<span className="group-type">{openGroupInvitation}</span>
<span className="group-address">{props.url}</span>
</span>
</div>
</div>
</ReadableMessage>
</StyledGroupInvitation>
</ExpirableReadableMessage>
);
};

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

@ -0,0 +1,82 @@
import React from 'react';
import { isEmpty } from 'lodash';
import styled from 'styled-components';
import { useIsPrivate, useIsPublic } from '../../../../hooks/useParamSelector';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../../../../interactions/conversationInteractions';
import { PropsForInteractionNotification } from '../../../../state/ducks/conversations';
import { assertUnreachable } from '../../../../types/sqlSharedTypes';
import { Flex } from '../../../basic/Flex';
import { ReadableMessage } from './ReadableMessage';
const StyledFailText = styled.div`
color: var(--danger-color);
`;
export const InteractionNotification = (props: PropsForInteractionNotification) => {
const { notificationType, convoId, messageId, receivedAt, isUnread } = props;
const { interactionStatus, interactionType } = notificationType;
const isGroup = !useIsPrivate(convoId);
const isCommunity = useIsPublic(convoId);
// NOTE at this time we don't show visible control messages in communities, that might change in future...
if (isCommunity) {
return null;
}
if (interactionStatus !== ConversationInteractionStatus.Error) {
// NOTE For now we only show interaction errors in the message history
return null;
}
let text = '';
switch (interactionType) {
case ConversationInteractionType.Hide:
// this can't happen
break;
case ConversationInteractionType.Leave:
text = isCommunity
? window.i18n('leaveCommunityFailedPleaseTryAgain')
: isGroup
? window.i18n('leaveGroupFailedPleaseTryAgain')
: window.i18n('deleteConversationFailedPleaseTryAgain');
break;
default:
assertUnreachable(
interactionType,
`InteractionErrorMessage: Missing case error "${interactionType}"`
);
}
if (isEmpty(text)) {
return null;
}
return (
<ReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
dataTestId="interaction-notification"
>
<Flex
id={`convo-interaction-${convoId}`}
container={true}
flexDirection="row"
alignItems="center"
justifyContent="center"
margin={'var(--margins-md) var(--margins-sm)'}
data-testid="control-message"
>
<StyledFailText>{text}</StyledFailText>
</Flex>
</ReadableMessage>
);
};

@ -2,6 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import { StateType } from '../../../../state/reducer';
import { getGenericReadableMessageSelectorProps } from '../../../../state/selectors/conversations';
import { THUMBNAIL_SIDE } from '../../../../types/attachments/VisualAttachment';
import { GenericReadableMessage } from './GenericReadableMessage';
@ -15,8 +16,8 @@ type Props = {
};
export const Message = (props: Props) => {
const msgProps = useSelector(state =>
getGenericReadableMessageSelectorProps(state as any, props.messageId)
const msgProps = useSelector((state: StateType) =>
getGenericReadableMessageSelectorProps(state, props.messageId)
);
const ctxMenuID = `ctx-menu-message-${uuidv4()}`;

@ -1,157 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useKey from 'react-use/lib/useKey';
import { Message } from './Message';
import { deleteMessagesById } from '../../../../interactions/conversations/unsendingInteractions';
import {
ContactPropsMessageDetail,
closeMessageDetailsView,
} from '../../../../state/ducks/conversations';
import { getMessageDetailsViewProps } from '../../../../state/selectors/conversations';
import { Avatar, AvatarSize } from '../../../avatar/Avatar';
import { ContactName } from '../../ContactName';
import { useMessageIsDeletable } from '../../../../state/selectors';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../../../basic/SessionButton';
const AvatarItem = (props: { pubkey: string }) => {
const { pubkey } = props;
return <Avatar size={AvatarSize.S} pubkey={pubkey} />;
};
const DeleteButtonItem = (props: { messageId: string; convoId: string; isDeletable: boolean }) => {
const { i18n } = window;
return props.isDeletable ? (
<div className="module-message-detail__delete-button-container">
<SessionButton
text={i18n('delete')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Solid}
onClick={async () => {
await deleteMessagesById([props.messageId], props.convoId);
}}
/>
</div>
) : null;
};
const ContactsItem = (props: { contacts: Array<ContactPropsMessageDetail> }) => {
const { contacts } = props;
if (!contacts || !contacts.length) {
return null;
}
return (
<div className="module-message-detail__contact-container">
{contacts.map(contact => (
<ContactItem key={contact.pubkey} contact={contact} />
))}
</div>
);
};
const ContactItem = (props: { contact: ContactPropsMessageDetail }) => {
const { contact } = props;
const errors = contact.errors || [];
const statusComponent = (
<div
className={classNames(
'module-message-detail__contact__status-icon',
`module-message-detail__contact__status-icon--${contact.status}`
)}
/>
);
return (
<div key={contact.pubkey} className="module-message-detail__contact">
<AvatarItem pubkey={contact.pubkey} />
<div className="module-message-detail__contact__text">
<div className="module-message-detail__contact__name">
<ContactName
pubkey={contact.pubkey}
name={contact.name}
profileName={contact.profileName}
shouldShowPubkey={true}
/>
</div>
{errors.map((error, index) => (
<div key={index} className="module-message-detail__contact__error">
{error.message}
</div>
))}
</div>
{statusComponent}
</div>
);
};
export const MessageDetail = () => {
const { i18n } = window;
const messageDetailProps = useSelector(getMessageDetailsViewProps);
const isDeletable = useMessageIsDeletable(messageDetailProps?.messageId);
const dispatch = useDispatch();
useKey('Escape', () => {
dispatch(closeMessageDetailsView());
});
if (!messageDetailProps) {
return null;
}
const { errors, receivedAt, sentAt, convoId, direction, messageId } = messageDetailProps;
return (
<div className="message-detail-wrapper">
<div className="module-message-detail">
<div className="module-message-detail__message-container">
<Message messageId={messageId} isDetailView={true} />
</div>
<table className="module-message-detail__info">
<tbody>
{(errors || []).map((error, index) => (
<tr key={index}>
<td className="module-message-detail__label">{i18n('error')}</td>
<td>
{' '}
<span className="error-message text-selectable">{error.message}</span>{' '}
</td>
</tr>
))}
<tr>
<td className="module-message-detail__label">{i18n('sent')}</td>
<td>
{moment(sentAt).format('LLLL')} <span>({sentAt})</span>
</td>
</tr>
{receivedAt ? (
<tr>
<td className="module-message-detail__label">{i18n('received')}</td>
<td>
{moment(receivedAt).format('LLLL')} <span>({receivedAt})</span>
</td>
</tr>
) : null}
<tr>
<td className="module-message-detail__label">
{direction === 'incoming' ? i18n('from') : i18n('to')}
</td>
</tr>
</tbody>
</table>
<ContactsItem contacts={messageDetailProps.contacts} />
<DeleteButtonItem convoId={convoId} messageId={messageId} isDeletable={isDeletable} />
</div>
</div>
);
};

@ -6,6 +6,7 @@ import { Flex } from '../../../basic/Flex';
import { SpacerSM, Text } from '../../../basic/Text';
import { ReadableMessage } from './ReadableMessage';
// Note this should not respond to the disappearing message conversation setting so we use the ReadableMessage
export const MessageRequestResponse = (props: PropsForMessageRequestResponse) => {
const { messageId, isUnread, receivedAt, conversationId } = props;
@ -26,6 +27,7 @@ export const MessageRequestResponse = (props: PropsForMessageRequestResponse) =>
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
dataTestId="message-request-response-message"
key={`readable-message-${messageId}`}
>
<Flex

@ -1,5 +1,12 @@
import { debounce, noop } from 'lodash';
import React, { useCallback, useContext, useLayoutEffect, useState } from 'react';
import React, {
AriaRole,
MouseEventHandler,
useCallback,
useContext,
useLayoutEffect,
useState,
} from 'react';
import { InView } from 'react-intersection-observer';
import { useDispatch, useSelector } from 'react-redux';
import { Data } from '../../../../data/data';
@ -23,13 +30,18 @@ import { getIsAppFocused } from '../../../../state/selectors/section';
import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation';
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
type ReadableMessageProps = {
export type ReadableMessageProps = {
children: React.ReactNode;
messageId: string;
className?: string;
receivedAt: number | undefined;
isUnread: boolean;
onClick?: MouseEventHandler<HTMLElement>;
onDoubleClickCapture?: MouseEventHandler<HTMLElement>;
role?: AriaRole;
dataTestId: string;
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
isControlMessage?: boolean;
};
const debouncedTriggerLoadMoreTop = debounce(
@ -57,7 +69,17 @@ const debouncedTriggerLoadMoreBottom = debounce(
);
export const ReadableMessage = (props: ReadableMessageProps) => {
const { messageId, onContextMenu, className, receivedAt, isUnread } = props;
const {
messageId,
onContextMenu,
className,
receivedAt,
isUnread,
onClick,
onDoubleClickCapture,
role,
dataTestId,
} = props;
const isAppFocused = useSelector(getIsAppFocused);
const dispatch = useDispatch();
@ -77,7 +99,6 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
// if this unread-indicator is rendered,
// we want to scroll here only if the conversation was not opened to a specific message
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (
@ -95,7 +116,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
});
const onVisible = useCallback(
async (inView: boolean | object) => {
async (inView: boolean, _: IntersectionObserverEntry) => {
if (!selectedConversationKey) {
return;
}
@ -106,7 +127,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
dispatch(showScrollToBottomButton(false));
getConversationController()
.get(selectedConversationKey)
?.markConversationRead(receivedAt || 0); // TODOLATER this should be `sentAt || serverTimestamp` I think
?.markConversationRead({ newestUnreadDate: receivedAt || 0, fromConfigMessage: false }); // TODOLATER this should be `sentAt || serverTimestamp` I think
dispatch(markConversationFullyRead(selectedConversationKey));
} else if (inView === false) {
@ -114,30 +135,16 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
}
}
if (
inView === true &&
isAppFocused &&
oldestMessageId === messageId &&
!fetchingMoreInProgress
) {
if (inView && isAppFocused && oldestMessageId === messageId && !fetchingMoreInProgress) {
debouncedTriggerLoadMoreTop(selectedConversationKey, oldestMessageId);
}
if (
inView === true &&
isAppFocused &&
youngestMessageId === messageId &&
!fetchingMoreInProgress
) {
if (inView && isAppFocused && youngestMessageId === messageId && !fetchingMoreInProgress) {
debouncedTriggerLoadMoreBottom(selectedConversationKey, youngestMessageId);
}
// this part is just handling the marking of the message as read if needed
if (
(inView === true ||
((inView as any).type === 'focus' && (inView as any).returnValue === true)) &&
isAppFocused
) {
if (inView) {
if (isUnread) {
// TODOLATER this is pretty expensive and should instead use values from the redux store
const found = await Data.getMessageById(messageId);
@ -151,7 +158,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
if (foundSentAt) {
getConversationController()
.get(selectedConversationKey)
?.markConversationRead(foundSentAt, Date.now());
?.markConversationRead({ newestUnreadDate: foundSentAt, fromConfigMessage: false });
}
}
}
@ -182,8 +189,11 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
onChange={isAppFocused ? onVisible : noop}
triggerOnce={false}
trackVisibility={true}
onClick={onClick}
onDoubleClickCapture={onDoubleClickCapture}
role={role}
key={`inview-msg-${messageId}`}
data-testid="control-message"
data-testid={dataTestId}
>
{props.children}
</InView>

@ -12,7 +12,7 @@ import {
} from '../../../../../state/selectors/selectedConversation';
import { LocalizerKeys } from '../../../../../types/LocalizerKeys';
import { SessionIconType } from '../../../../icon';
import { ReadableMessage } from '../ReadableMessage';
import { ExpirableReadableMessage } from '../ExpirableReadableMessage';
import { NotificationBubble } from './NotificationBubble';
type StyleType = Record<
@ -39,7 +39,7 @@ const style: StyleType = {
};
export const CallNotification = (props: PropsForCallNotification) => {
const { messageId, receivedAt, isUnread, notificationType } = props;
const { messageId, notificationType } = props;
const selectedConvoId = useSelectedConversationKey();
const displayNameInProfile = useSelectedDisplayNameInProfile();
@ -59,17 +59,17 @@ export const CallNotification = (props: PropsForCallNotification) => {
const iconColor = styleItem.iconColor;
return (
<ReadableMessage
<ExpirableReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
dataTestId={`call-notification-${notificationType}`}
isControlMessage={true}
>
<NotificationBubble
notificationText={notificationText}
iconType={iconType}
iconColor={iconColor}
/>
</ReadableMessage>
</ExpirableReadableMessage>
);
};

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

@ -1,16 +1,23 @@
import React, { ReactElement, useRef, useState } from 'react';
import { useMouse } from 'react-use';
import styled from 'styled-components';
import { useRightOverlayMode } from '../../../../hooks/useUI';
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { UserUtils } from '../../../../session/utils';
import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation';
import { THEME_GLOBALS } from '../../../../themes/globals';
import { SortedReactionList } from '../../../../types/Reaction';
import { abbreviateNumber } from '../../../../util/abbreviateNumber';
import { nativeEmojiData } from '../../../../util/emoji';
import { popupXDefault, popupYDefault } from '../message-content/MessageReactions';
import { POPUP_WIDTH, ReactionPopup, TipPosition } from './ReactionPopup';
const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; showCount: boolean }>`
const StyledReaction = styled.button<{
selected: boolean;
inModal: boolean;
showCount: boolean;
hasOnClick?: boolean;
}>`
display: flex;
justify-content: ${props => (props.showCount ? 'flex-start' : 'center')};
align-items: center;
@ -29,6 +36,8 @@ const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; show
span {
width: 100%;
}
${props => !props.hasOnClick && 'cursor: not-allowed;'}
`;
const StyledReactionContainer = styled.div<{
@ -46,7 +55,7 @@ export type ReactionProps = {
inGroup: boolean;
handlePopupX: (x: number) => void;
handlePopupY: (y: number) => void;
onClick: (emoji: string) => void;
onClick?: (emoji: string) => void;
popupReaction?: string;
onSelected?: (emoji: string) => boolean;
handlePopupReaction?: (emoji: string) => void;
@ -69,6 +78,7 @@ export const Reaction = (props: ReactionProps): ReactElement => {
handlePopupClick,
} = props;
const rightOverlayMode = useRightOverlayMode();
const isMessageSelection = useIsMessageSelectionMode();
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const senders = reactionsMap[emoji]?.senders || [];
@ -76,7 +86,7 @@ export const Reaction = (props: ReactionProps): ReactElement => {
const showCount = count !== undefined && (count > 1 || inGroup);
const reactionRef = useRef<HTMLDivElement>(null);
const { docX, elW } = useMouse(reactionRef);
const { docX: _docX, elW } = useMouse(reactionRef);
const gutterWidth = 380; // TODOLATER make this a variable which can be shared in CSS and JS
const tooltipMidPoint = POPUP_WIDTH / 2; // px
@ -96,7 +106,9 @@ export const Reaction = (props: ReactionProps): ReactElement => {
const handleReactionClick = () => {
if (!isMessageSelection) {
onClick(emoji);
if (onClick) {
onClick(emoji);
}
}
};
@ -107,12 +119,28 @@ export const Reaction = (props: ReactionProps): ReactElement => {
selected={selected()}
inModal={inModal}
onClick={handleReactionClick}
hasOnClick={Boolean(onClick)}
onMouseEnter={() => {
if (inGroup && !isMessageSelection) {
const { innerWidth: windowWidth } = window;
const { innerWidth } = window;
let windowWidth = innerWidth;
let docX = _docX;
// if the right panel is open we may need to show a reaction tooltip relative to it
if (rightOverlayMode && rightOverlayMode.type === 'message_info') {
const rightPanelWidth = Number(THEME_GLOBALS['--right-panel-width'].split('px')[0]);
// we need to check that the reaction we are hovering over is inside of the right panel and not in the messages list
if (docX > windowWidth - rightPanelWidth) {
// make the values relative to the right panel
docX = docX - windowWidth + rightPanelWidth;
windowWidth = rightPanelWidth;
}
}
if (handlePopupReaction) {
// overflow on far right means we shift left
if (docX + elW + tooltipMidPoint > windowWidth) {
if (docX + elW + tooltipMidPoint > innerWidth) {
handlePopupX(Math.abs(popupXDefault) * 1.5 * -1);
setTooltipPosition('right');
// overflow onto conversations means we lock to the right

@ -0,0 +1,132 @@
import React from 'react';
import styled from 'styled-components';
import { useRightOverlayMode } from '../../../hooks/useUI';
import { isRtlBody } from '../../../util/i18n';
import { Flex } from '../../basic/Flex';
import { OverlayRightPanelSettings } from './overlay/OverlayRightPanelSettings';
import { OverlayDisappearingMessages } from './overlay/disappearing-messages/OverlayDisappearingMessages';
import { OverlayMessageInfo } from './overlay/message-info/OverlayMessageInfo';
export const StyledRightPanelContainer = styled.div`
position: absolute;
height: var(--right-panel-height);
right: 0vw;
transition: transform 0.3s ease-in-out;
transform: translateX(100%);
will-change: transform;
width: var(--right-panel-width);
z-index: 5;
background-color: var(--background-primary-color);
border-left: 1px solid var(--border-color);
&.show {
transform: none;
transition: transform 0.3s ease-in-out;
z-index: 3;
}
`;
const StyledRightPanel = styled(Flex)`
h2 {
word-break: break-word;
}
.description {
margin: var(--margins-md) 0;
min-height: 4rem;
width: inherit;
color: var(--text-secondary-color);
text-align: center;
display: none;
}
// no double border (top and bottom) between two elements
&-item + &-item {
border-top: none;
}
.module-empty-state {
text-align: center;
}
.module-attachment-section__items {
&-media {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
}
&-documents {
width: 100%;
}
}
.module-media {
&-gallery {
&__tab-container {
padding-top: 1rem;
}
&__tab {
color: var(--text-primary-color);
font-weight: bold;
font-size: 0.9rem;
padding: 0.6rem;
opacity: 0.8;
&--active {
border-bottom: none;
opacity: 1;
&:after {
content: ''; /* This is necessary for the pseudo element to work. */
display: block;
margin: 0 auto;
width: 70%;
padding-top: 0.5rem;
border-bottom: 4px solid var(--primary-color);
}
}
}
&__content {
padding: var(--margins-xs);
margin-bottom: 1vh;
.module-media-grid-item__image,
.module-media-grid-item {
height: calc(
var(--right-panel-width) / 4
); //.right-panel is var(--right-panel-width) and we want three rows with some space so divide it by 4
width: calc(
var(--right-panel-width) / 4
); //.right-panel is var(--right-panel-width) and we want three rows with some space so divide it by 4
margin: auto;
}
}
}
}
`;
const ClosableOverlay = () => {
const rightOverlayMode = useRightOverlayMode();
switch (rightOverlayMode?.type) {
case 'disappearing_messages':
return <OverlayDisappearingMessages />;
case 'message_info':
return <OverlayMessageInfo />;
default:
return <OverlayRightPanelSettings />;
}
};
export const RightPanel = () => {
const isRtlMode = isRtlBody();
return (
<StyledRightPanel
container={true}
flexDirection={'column'}
alignItems={'center'}
width={'var(--right-panel-width)'}
height={'var(--right-panel-height)'}
className="right-panel"
style={{ direction: isRtlMode ? 'rtl' : 'initial' }}
>
<ClosableOverlay />
</StyledRightPanel>
);
};

@ -0,0 +1,358 @@
import { compact, flatten, isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import useInterval from 'react-use/lib/useInterval';
import styled from 'styled-components';
import { Data } from '../../../../data/data';
import { SessionIconButton } from '../../../icon';
import {
useConversationUsername,
useDisappearingMessageSettingText,
} from '../../../../hooks/useParamSelector';
import { useIsRightPanelShowing } from '../../../../hooks/useUI';
import {
ConversationInteractionStatus,
ConversationInteractionType,
showAddModeratorsByConvoId,
showInviteContactByConvoId,
showLeaveGroupByConvoId,
showRemoveModeratorsByConvoId,
showUpdateGroupMembersByConvoId,
showUpdateGroupNameByConvoId,
} from '../../../../interactions/conversationInteractions';
import { Constants } from '../../../../session';
import { closeRightPanel } from '../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../state/ducks/section';
import {
useSelectedConversationKey,
useSelectedDisplayNameInProfile,
useSelectedIsActive,
useSelectedIsBlocked,
useSelectedIsGroupOrCommunity,
useSelectedIsKickedFromGroup,
useSelectedIsLeft,
useSelectedIsPublic,
useSelectedLastMessage,
useSelectedSubscriberCount,
useSelectedWeAreAdmin,
} from '../../../../state/selectors/selectedConversation';
import { AttachmentTypeWithPath } from '../../../../types/Attachment';
import { getAbsoluteAttachmentPath } from '../../../../types/MessageAttachment';
import { Avatar, AvatarSize } from '../../../avatar/Avatar';
import { Flex } from '../../../basic/Flex';
import { SpacerLG, SpacerMD, SpacerXL } from '../../../basic/Text';
import { PanelButtonGroup, PanelIconButton } from '../../../buttons';
import { MediaItemType } from '../../../lightbox/LightboxGallery';
import { MediaGallery } from '../../media-gallery/MediaGallery';
import { Header, StyledScrollContainer } from './components';
async function getMediaGalleryProps(
conversationId: string
): Promise<{
documents: Array<MediaItemType>;
media: Array<MediaItemType>;
}> {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const rawMedia = await Data.getMessagesWithVisualMediaAttachments(
conversationId,
Constants.CONVERSATION.DEFAULT_MEDIA_FETCH_COUNT
);
const rawDocuments = await Data.getMessagesWithFileAttachments(
conversationId,
Constants.CONVERSATION.DEFAULT_DOCUMENTS_FETCH_COUNT
);
const media = flatten(
rawMedia.map(attributes => {
const { attachments, source, id, timestamp, serverTimestamp, received_at } = attributes;
return (attachments || [])
.filter(
(attachment: AttachmentTypeWithPath) =>
attachment.thumbnail && !attachment.pending && !attachment.error
)
.map((attachment: AttachmentTypeWithPath, index: number) => {
const { thumbnail } = attachment;
const mediaItem: MediaItemType = {
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail ? getAbsoluteAttachmentPath(thumbnail.path) : undefined,
contentType: attachment.contentType || '',
index,
messageTimestamp: timestamp || serverTimestamp || received_at || 0,
messageSender: source,
messageId: id,
attachment,
};
return mediaItem;
});
})
);
// Unlike visual media, only one non-image attachment is supported
const documents = rawDocuments.map(attributes => {
// this is to not fail if the attachment is invalid (could be a Long Attachment type which is not supported)
if (!attributes.attachments?.length) {
// window?.log?.info(
// 'Got a message with an empty list of attachment. Skipping...'
// );
return null;
}
const attachment = attributes.attachments[0];
const { source, id, timestamp, serverTimestamp, received_at } = attributes;
return {
contentType: attachment.contentType,
index: 0,
attachment,
messageTimestamp: timestamp || serverTimestamp || received_at || 0,
messageSender: source,
messageId: id,
};
});
return {
media,
documents: compact(documents), // remove null
};
}
const HeaderItem = () => {
const selectedConvoKey = useSelectedConversationKey();
const displayNameInProfile = useSelectedDisplayNameInProfile();
const dispatch = useDispatch();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroupOrCommunity();
const subscriberCount = useSelectedSubscriberCount();
if (!selectedConvoKey) {
return null;
}
const showInviteContacts = isGroup && !isKickedFromGroup && !isBlocked && !left;
const showMemberCount = !!(subscriberCount && subscriberCount > 0);
return (
<Header
backButtonDirection="right"
backButtonOnClick={() => {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}}
hideCloseButton={true}
>
<Flex
container={true}
justifyContent={'center'}
alignItems={'center'}
width={'100%'}
style={{ position: 'relative' }}
>
<Avatar size={AvatarSize.XL} pubkey={selectedConvoKey} />
{showInviteContacts && (
<SessionIconButton
iconType="addUser"
iconSize="medium"
onClick={() => {
if (selectedConvoKey) {
showInviteContactByConvoId(selectedConvoKey);
}
}}
style={{ position: 'absolute', right: '0px', top: '4px' }}
dataTestId="add-user-button"
/>
)}
</Flex>
<StyledName data-testid="right-panel-group-name">{displayNameInProfile}</StyledName>
{showMemberCount && (
<Flex container={true} flexDirection={'column'}>
<div role="button" className="subtle">
{window.i18n('members', [`${subscriberCount}`])}
</div>
<SpacerMD />
</Flex>
)}
</Header>
);
};
const StyledName = styled.h4`
padding-inline: var(--margins-md);
font-size: var(--font-size-md);
`;
export const OverlayRightPanelSettings = () => {
const [documents, setDocuments] = useState<Array<MediaItemType>>([]);
const [media, setMedia] = useState<Array<MediaItemType>>([]);
const selectedConvoKey = useSelectedConversationKey();
const selectedUsername = useConversationUsername(selectedConvoKey) || selectedConvoKey;
const isShowing = useIsRightPanelShowing();
const dispatch = useDispatch();
const isActive = useSelectedIsActive();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroupOrCommunity();
const isPublic = useSelectedIsPublic();
const weAreAdmin = useSelectedWeAreAdmin();
const disappearingMessagesSubtitle = useDisappearingMessageSettingText({
convoId: selectedConvoKey,
separator: ': ',
});
const lastMessage = useSelectedLastMessage();
useEffect(() => {
let isCancelled = false;
const loadDocumentsOrMedia = async () => {
try {
if (isShowing && selectedConvoKey) {
const results = await getMediaGalleryProps(selectedConvoKey);
if (!isCancelled) {
if (!isEqual(documents, results.documents)) {
setDocuments(results.documents);
}
if (!isEqual(media, results.media)) {
setMedia(results.media);
}
}
}
} catch (error) {
if (!isCancelled) {
window.log.debug(`OverlayRightPanelSettings loadDocumentsOrMedia: ${error}`);
}
}
};
void loadDocumentsOrMedia();
return () => {
isCancelled = true;
};
}, [documents, isShowing, media, selectedConvoKey]);
useInterval(async () => {
if (isShowing && selectedConvoKey) {
const results = await getMediaGalleryProps(selectedConvoKey);
if (results.documents.length !== documents.length || results.media.length !== media.length) {
setDocuments(results.documents);
setMedia(results.media);
}
}
}, 10000);
if (!selectedConvoKey) {
return null;
}
const commonNoShow = isKickedFromGroup || left || isBlocked || !isActive;
const hasDisappearingMessages = !isPublic && !commonNoShow;
const leaveGroupString = isPublic
? window.i18n('leaveCommunity')
: lastMessage?.interactionType === ConversationInteractionType.Leave &&
lastMessage?.interactionStatus === ConversationInteractionStatus.Error
? window.i18n('deleteConversation')
: isKickedFromGroup
? window.i18n('youGotKickedFromGroup')
: left
? window.i18n('youLeftTheGroup')
: window.i18n('leaveGroup');
const showUpdateGroupNameButton = isGroup && weAreAdmin && !commonNoShow; // legacy groups non-admin cannot change groupname anymore
const showAddRemoveModeratorsButton = weAreAdmin && !commonNoShow && isPublic;
const showUpdateGroupMembersButton = !isPublic && isGroup && !commonNoShow;
const deleteConvoAction = async () => {
await showLeaveGroupByConvoId(selectedConvoKey, selectedUsername);
};
return (
<StyledScrollContainer>
<Flex container={true} flexDirection={'column'} alignItems={'center'}>
<HeaderItem />
<PanelButtonGroup style={{ margin: '0 var(--margins-lg)' }}>
{showUpdateGroupNameButton && (
<PanelIconButton
iconType={'group'}
text={isPublic ? window.i18n('editGroup') : window.i18n('editGroupName')}
onClick={() => {
void showUpdateGroupNameByConvoId(selectedConvoKey);
}}
dataTestId="edit-group-name"
/>
)}
{showAddRemoveModeratorsButton && (
<>
<PanelIconButton
iconType={'addModerator'}
text={window.i18n('addModerators')}
onClick={() => {
showAddModeratorsByConvoId(selectedConvoKey);
}}
dataTestId="add-moderators"
/>
<PanelIconButton
iconType={'deleteModerator'}
text={window.i18n('removeModerators')}
onClick={() => {
showRemoveModeratorsByConvoId(selectedConvoKey);
}}
dataTestId="remove-moderators"
/>
</>
)}
{showUpdateGroupMembersButton && (
<PanelIconButton
iconType={'group'}
text={window.i18n('groupMembers')}
onClick={() => {
void showUpdateGroupMembersByConvoId(selectedConvoKey);
}}
dataTestId="group-members"
/>
)}
{hasDisappearingMessages && (
<PanelIconButton
iconType={'timer50'}
text={window.i18n('disappearingMessages')}
subtitle={disappearingMessagesSubtitle}
dataTestId="disappearing-messages"
onClick={() => {
dispatch(setRightOverlayMode({ type: 'disappearing_messages', params: null }));
}}
/>
)}
<MediaGallery documents={documents} media={media} />
{isGroup && (
<PanelIconButton
text={leaveGroupString}
dataTestId="leave-group-button"
disabled={isKickedFromGroup || left}
onClick={() => void deleteConvoAction()}
color={'var(--danger-color)'}
iconType={'delete'}
/>
)}
</PanelButtonGroup>
<SpacerLG />
<SpacerXL />
</Flex>
</StyledScrollContainer>
);
};

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const StyledScrollContainer = styled.div`
width: 100%;
height: 100%;
overflow: hidden auto;
`;

@ -0,0 +1,89 @@
import React, { ReactNode } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { closeRightPanel } from '../../../../../state/ducks/conversations';
import { resetRightOverlayMode } from '../../../../../state/ducks/section';
import { Flex } from '../../../../basic/Flex';
import { SessionIconButton } from '../../../../icon';
export const HeaderTitle = styled.h2`
font-family: var(--font-default);
font-size: var(--font-size-h2);
text-align: center;
margin-top: 0px;
margin-bottom: 0px;
`;
export const HeaderSubtitle = styled.h3`
font-family: var(--font-default);
font-size: 11px;
font-weight: 400;
text-align: center;
padding-top: 0px;
margin-top: 0;
`;
type HeaderProps = {
hideBackButton?: boolean;
backButtonDirection?: 'left' | 'right';
backButtonOnClick?: () => void;
hideCloseButton?: boolean;
closeButtonOnClick?: () => void;
children?: ReactNode;
};
export const Header = (props: HeaderProps) => {
const {
children,
hideBackButton = false,
backButtonDirection = 'left',
backButtonOnClick,
hideCloseButton = false,
closeButtonOnClick,
} = props;
const dispatch = useDispatch();
return (
<Flex container={true} width={'100%'} padding={'32px var(--margins-lg) var(--margins-md)'}>
{!hideBackButton && (
<SessionIconButton
iconSize={'medium'}
iconType={'chevron'}
iconRotation={backButtonDirection === 'left' ? 90 : 270}
onClick={() => {
if (backButtonOnClick) {
backButtonOnClick();
} else {
dispatch(resetRightOverlayMode());
}
}}
dataTestId="back-button-conversation-options"
/>
)}
<Flex
container={true}
flexDirection={'column'}
justifyContent={'flex-start'}
alignItems={'center'}
width={'100%'}
margin={'-5px auto auto'}
>
{children}
</Flex>
{!hideCloseButton && (
<SessionIconButton
iconSize={'tiny'}
iconType={'exit'}
onClick={() => {
if (closeButtonOnClick) {
closeButtonOnClick();
} else {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}
}}
/>
)}
</Flex>
);
};

@ -0,0 +1,4 @@
import { StyledScrollContainer } from './Containers';
import { Header, HeaderSubtitle, HeaderTitle } from './Header';
export { Header, HeaderSubtitle, HeaderTitle, StyledScrollContainer };

@ -0,0 +1,77 @@
import React from 'react';
import { DisappearingMessageConversationModeType } from '../../../../../session/disappearing_messages/types';
import { PanelButtonGroup, PanelLabel } from '../../../../buttons/PanelButton';
import { PanelRadioButton } from '../../../../buttons/PanelRadioButton';
function loadDataTestId(mode: DisappearingMessageConversationModeType) {
const dataTestId = 'disappear-%-option';
switch (mode) {
case 'legacy':
return dataTestId.replace('%', 'legacy');
case 'deleteAfterRead':
return dataTestId.replace('%', 'after-read');
case 'deleteAfterSend':
return dataTestId.replace('%', 'after-send');
case 'off':
default:
return dataTestId.replace('%', 'off');
}
}
type DisappearingModesProps = {
options: Record<DisappearingMessageConversationModeType, boolean>;
selected?: DisappearingMessageConversationModeType;
setSelected: (value: DisappearingMessageConversationModeType) => void;
hasOnlyOneMode?: boolean;
};
export const DisappearingModes = (props: DisappearingModesProps) => {
const { options, selected, setSelected, hasOnlyOneMode } = props;
if (hasOnlyOneMode) {
return null;
}
return (
<>
<PanelLabel>{window.i18n('disappearingMessagesModeLabel')}</PanelLabel>
<PanelButtonGroup>
{Object.keys(options).map(_mode => {
const mode = _mode as DisappearingMessageConversationModeType;
const optionI18n =
mode === 'legacy'
? window.i18n('disappearingMessagesModeLegacy')
: mode === 'deleteAfterRead'
? window.i18n('disappearingMessagesModeAfterRead')
: mode === 'deleteAfterSend'
? window.i18n('disappearingMessagesModeAfterSend')
: window.i18n('disappearingMessagesModeOff');
const subtitleI18n =
mode === 'legacy'
? window.i18n('disappearingMessagesModeLegacySubtitle')
: mode === 'deleteAfterRead'
? window.i18n('disappearingMessagesModeAfterReadSubtitle')
: mode === 'deleteAfterSend'
? window.i18n('disappearingMessagesModeAfterSendSubtitle')
: undefined;
return (
<PanelRadioButton
key={mode}
text={optionI18n}
subtitle={subtitleI18n}
value={mode}
isSelected={selected === mode}
onSelect={() => {
setSelected(mode);
}}
disabled={options[mode]}
dataTestId={loadDataTestId(mode)}
/>
);
})}
</PanelButtonGroup>
</>
);
};

@ -0,0 +1,237 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { useTimerOptionsByMode } from '../../../../../hooks/useParamSelector';
import { setDisappearingMessagesByConvoId } from '../../../../../interactions/conversationInteractions';
import { TimerOptions } from '../../../../../session/disappearing_messages/timerOptions';
import { DisappearingMessageConversationModeType } from '../../../../../session/disappearing_messages/types';
import { closeRightPanel } from '../../../../../state/ducks/conversations';
import { resetRightOverlayMode } from '../../../../../state/ducks/section';
import {
getSelectedConversationExpirationModes,
useSelectedConversationDisappearingMode,
useSelectedConversationKey,
useSelectedExpireTimer,
useSelectedIsGroupOrCommunity,
useSelectedWeAreAdmin,
} from '../../../../../state/selectors/selectedConversation';
import { ReleasedFeatures } from '../../../../../util/releaseFeature';
import { Flex } from '../../../../basic/Flex';
import { SessionButton } from '../../../../basic/SessionButton';
import { SpacerLG } from '../../../../basic/Text';
import { Header, HeaderSubtitle, HeaderTitle, StyledScrollContainer } from '../components';
import { DisappearingModes } from './DisappearingModes';
import { TimeOptions } from './TimeOptions';
const ButtonSpacer = styled.div`
height: 80px;
`;
const StyledButtonContainer = styled.div`
background: linear-gradient(0deg, var(--background-primary-color), transparent);
position: absolute;
width: 100%;
bottom: 0px;
.session-button {
font-weight: 500;
min-width: 90px;
width: fit-content;
margin: 35px auto 10px;
}
`;
const StyledNonAdminDescription = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin: 0 var(--margins-lg);
color: var(--text-secondary-color);
font-size: var(--font-size-xs);
text-align: center;
line-height: 15px;
`;
// TODO legacy messages support will be removed in a future release
function loadDefaultTimeValue(
modeSelected: DisappearingMessageConversationModeType | undefined,
hasOnlyOneMode: boolean
) {
// NOTE if there is only 1 disappearing message mode available the default state is that it is turned off
if (hasOnlyOneMode) {
return 0;
}
return modeSelected !== 'off'
? modeSelected !== 'legacy'
? modeSelected === 'deleteAfterSend'
? TimerOptions.DEFAULT_OPTIONS.DELETE_AFTER_SEND
: TimerOptions.DEFAULT_OPTIONS.DELETE_AFTER_READ
: TimerOptions.DEFAULT_OPTIONS.LEGACY
: 0;
}
/** if there is only one disappearing message mode and 'off' enabled then we trigger single mode UI */
function useSingleMode(disappearingModeOptions: any) {
const singleMode: DisappearingMessageConversationModeType | undefined =
disappearingModeOptions &&
disappearingModeOptions.off !== undefined &&
Object.keys(disappearingModeOptions).length === 2
? (Object.keys(disappearingModeOptions)[1] as DisappearingMessageConversationModeType)
: undefined;
const hasOnlyOneMode = Boolean(singleMode && singleMode.length > 0);
return { singleMode, hasOnlyOneMode };
}
// TODO legacy messages support will be removed in a future release
function useLegacyModeBeforeV2Release(
isV2Released: boolean,
expirationMode: DisappearingMessageConversationModeType | undefined,
setModeSelected: (mode: DisappearingMessageConversationModeType | undefined) => void
) {
useEffect(() => {
if (!isV2Released) {
setModeSelected(
expirationMode === 'deleteAfterRead' || expirationMode === 'deleteAfterSend'
? 'legacy'
: expirationMode
);
}
}, [expirationMode, isV2Released, setModeSelected]);
}
export type PropsForExpirationSettings = {
expirationMode: DisappearingMessageConversationModeType | undefined;
expireTimer: number | undefined;
isGroup: boolean | undefined;
weAreAdmin: boolean | undefined;
};
export const OverlayDisappearingMessages = () => {
const dispatch = useDispatch();
const selectedConversationKey = useSelectedConversationKey();
const disappearingModeOptions = useSelector(getSelectedConversationExpirationModes);
const { singleMode, hasOnlyOneMode } = useSingleMode(disappearingModeOptions);
const isGroup = useSelectedIsGroupOrCommunity();
const expirationMode = useSelectedConversationDisappearingMode();
const expireTimer = useSelectedExpireTimer();
const weAreAdmin = useSelectedWeAreAdmin();
const [modeSelected, setModeSelected] = useState<
DisappearingMessageConversationModeType | undefined
>(hasOnlyOneMode ? singleMode : expirationMode);
const [timeSelected, setTimeSelected] = useState(expireTimer || 0);
const timerOptions = useTimerOptionsByMode(modeSelected, hasOnlyOneMode);
const isV2Released = ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached();
const handleSetMode = async () => {
if (hasOnlyOneMode) {
if (selectedConversationKey && singleMode) {
await setDisappearingMessagesByConvoId(
selectedConversationKey,
timeSelected === 0 ? 'off' : singleMode,
timeSelected
);
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}
return;
}
if (selectedConversationKey && modeSelected) {
await setDisappearingMessagesByConvoId(selectedConversationKey, modeSelected, timeSelected);
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}
};
useLegacyModeBeforeV2Release(isV2Released, expirationMode, setModeSelected);
useEffect(() => {
// NOTE loads a time value from the conversation model or the default
setTimeSelected(
expireTimer !== undefined && expireTimer > -1
? expireTimer
: loadDefaultTimeValue(modeSelected, hasOnlyOneMode)
);
}, [expireTimer, hasOnlyOneMode, modeSelected]);
if (!disappearingModeOptions) {
return null;
}
if (!selectedConversationKey) {
return null;
}
return (
<StyledScrollContainer>
<Flex container={true} flexDirection={'column'} alignItems={'center'}>
<Header>
<HeaderTitle>{window.i18n('disappearingMessages')}</HeaderTitle>
<HeaderSubtitle>
{singleMode === 'deleteAfterRead'
? window.i18n('disappearingMessagesModeAfterReadSubtitle')
: singleMode === 'deleteAfterSend'
? window.i18n('disappearingMessagesModeAfterSendSubtitle')
: window.i18n('settingAppliesToYourMessages')}
</HeaderSubtitle>
</Header>
<DisappearingModes
options={disappearingModeOptions}
selected={modeSelected}
setSelected={setModeSelected}
hasOnlyOneMode={hasOnlyOneMode}
/>
{(hasOnlyOneMode || modeSelected !== 'off') && (
<>
{!hasOnlyOneMode && <SpacerLG />}
<TimeOptions
options={timerOptions}
selected={timeSelected}
setSelected={setTimeSelected}
hasOnlyOneMode={hasOnlyOneMode}
disabled={
singleMode
? disappearingModeOptions[singleMode]
: modeSelected
? disappearingModeOptions[modeSelected]
: undefined
}
/>
</>
)}
{isGroup && isV2Released && !weAreAdmin && (
<>
<SpacerLG />
<StyledNonAdminDescription>
{window.i18n('settingAppliesToEveryone')}
<br />
{window.i18n('onlyGroupAdminsCanChange')}
</StyledNonAdminDescription>
</>
)}
<ButtonSpacer />
<StyledButtonContainer>
<SessionButton
onClick={handleSetMode}
disabled={
singleMode
? disappearingModeOptions[singleMode]
: modeSelected
? disappearingModeOptions[modeSelected]
: undefined
}
dataTestId={'disappear-set-button'}
>
{window.i18n('set')}
</SessionButton>
</StyledButtonContainer>
</Flex>
</StyledScrollContainer>
);
};

@ -0,0 +1,44 @@
import { isEmpty } from 'lodash';
import React from 'react';
import { TimerOptionsArray } from '../../../../../session/disappearing_messages/timerOptions';
import { PanelButtonGroup, PanelLabel } from '../../../../buttons/PanelButton';
import { PanelRadioButton } from '../../../../buttons/PanelRadioButton';
type TimerOptionsProps = {
options: TimerOptionsArray | null;
selected: number;
setSelected: (value: number) => void;
hasOnlyOneMode?: boolean;
disabled?: boolean;
};
export const TimeOptions = (props: TimerOptionsProps) => {
const { options, selected, setSelected, hasOnlyOneMode, disabled } = props;
if (!options || isEmpty(options)) {
return null;
}
return (
<>
{!hasOnlyOneMode && <PanelLabel>{window.i18n('timer')}</PanelLabel>}
<PanelButtonGroup>
{options.map(option => {
return (
<PanelRadioButton
key={option.name}
text={option.name}
value={option.name}
isSelected={selected === option.value}
onSelect={() => {
setSelected(option.value);
}}
disabled={disabled}
dataTestId={`time-option-${option.name.replace(' ', '-')}`} // we want "time-option-1-minute", etc as accessibility id
/>
);
})}
</PanelButtonGroup>
</>
);
};

@ -0,0 +1,346 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
// tslint:disable-next-line: no-submodule-imports
import useKey from 'react-use/lib/useKey';
import { PropsForAttachment, closeRightPanel } from '../../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../../state/ducks/section';
import { getMessageInfoId } from '../../../../../state/selectors/conversations';
import { Flex } from '../../../../basic/Flex';
import { Header, HeaderTitle, StyledScrollContainer } from '../components';
import { Data } from '../../../../../data/data';
import { useRightOverlayMode } from '../../../../../hooks/useUI';
import {
replyToMessage,
resendMessage,
} from '../../../../../interactions/conversationInteractions';
import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions';
import {
useMessageAttachments,
useMessageDirection,
useMessageIsDeletable,
useMessageQuote,
useMessageSender,
useMessageServerTimestamp,
useMessageText,
useMessageTimestamp,
} from '../../../../../state/selectors';
import { useSelectedConversationKey } from '../../../../../state/selectors/selectedConversation';
import { canDisplayImage } from '../../../../../types/Attachment';
import { isAudio } from '../../../../../types/MIME';
import {
getAudioDuration,
getVideoDuration,
} from '../../../../../types/attachments/VisualAttachment';
import { GoogleChrome } from '../../../../../util';
import { saveAttachmentToDisk } from '../../../../../util/attachmentsUtil';
import { SpacerLG, SpacerMD, SpacerXL } from '../../../../basic/Text';
import { PanelButtonGroup, PanelIconButton } from '../../../../buttons';
import { Message } from '../../../message/message-item/Message';
import { AttachmentInfo, MessageInfo } from './components';
import { AttachmentCarousel } from './components/AttachmentCarousel';
// NOTE we override the default max-widths when in the detail isDetailView
const StyledMessageBody = styled.div`
padding-bottom: var(--margins-lg);
.module-message {
pointer-events: none;
max-width: 100%;
@media (min-width: 1200px) {
max-width: 100%;
}
}
`;
const MessageBody = ({
messageId,
supportsAttachmentCarousel,
}: {
messageId: string;
supportsAttachmentCarousel: boolean;
}) => {
const quote = useMessageQuote(messageId);
const text = useMessageText(messageId);
// NOTE we don't want to render the message body if it's empty and the attachments carousel can render it instead
if (supportsAttachmentCarousel && !text && !quote) {
return null;
}
return (
<StyledMessageBody>
<Message messageId={messageId} isDetailView={true} />
</StyledMessageBody>
);
};
const StyledMessageInfoContainer = styled.div`
height: calc(100% - 48px);
width: 100%;
max-width: 650px;
overflow: hidden auto;
z-index: 2;
margin-inline-start: auto;
margin-inline-end: auto;
padding: var(--margins-sm) var(--margins-2xl) var(--margins-lg);
`;
type MessageInfoProps = {
errors: Array<Error>;
attachments: Array<PropsForAttachment>;
};
async function getPropsForMessageInfo(
messageId: string | undefined,
attachments: Array<PropsForAttachment>
): Promise<MessageInfoProps | null> {
if (!messageId) {
return null;
}
const found = await Data.getMessageById(messageId);
const attachmentsWithMediaDetails: Array<PropsForAttachment> = [];
if (found) {
// process attachments so we have the fileSize, url and screenshots
for (let i = 0; i < attachments.length; i++) {
const props = found.getPropsForAttachment(attachments[i]);
if (
props?.contentType &&
GoogleChrome.isVideoTypeSupported(props?.contentType) &&
!props.duration &&
props.url
) {
// eslint-disable-next-line no-await-in-loop
const duration = await getVideoDuration({
objectUrl: props.url,
contentType: props.contentType,
});
attachmentsWithMediaDetails.push({
...props,
duration,
});
} else if (props?.contentType && isAudio(props.contentType) && !props.duration && props.url) {
// eslint-disable-next-line no-await-in-loop
const duration = await getAudioDuration({
objectUrl: props.url,
contentType: props.contentType,
});
attachmentsWithMediaDetails.push({
...props,
duration,
});
} else if (props) {
attachmentsWithMediaDetails.push(props);
}
}
// This will make the error message for outgoing key errors a bit nicer
const errors = (found.get('errors') || []).map((error: any) => {
return error;
});
const toRet: MessageInfoProps = {
errors,
attachments: attachmentsWithMediaDetails,
};
return toRet;
}
return null;
}
function useMessageInfo(messageId: string | undefined) {
const [details, setDetails] = useState<MessageInfoProps | null>(null);
const fromState = useMessageAttachments(messageId);
// this is not ideal, but also doesn't seem to create any performance issue at the moment.
// TODO: ideally, we'd want to save the attachment duration anytime we save one to the disk (incoming/outgoing), and just retrieve it from the redux state here.
useEffect(() => {
let mounted = true;
// eslint-disable-next-line more/no-then
void getPropsForMessageInfo(messageId, fromState || [])
.then(result => {
if (mounted) {
setDetails(result);
}
})
.catch(window.log.error);
return () => {
mounted = false;
};
}, [fromState, messageId]);
return details;
}
export const OverlayMessageInfo = () => {
const dispatch = useDispatch();
const rightOverlayMode = useRightOverlayMode();
const messageId = useSelector(getMessageInfoId);
const messageInfo = useMessageInfo(messageId);
const isDeletable = useMessageIsDeletable(messageId);
const direction = useMessageDirection(messageId);
const timestamp = useMessageTimestamp(messageId);
const serverTimestamp = useMessageServerTimestamp(messageId);
const sender = useMessageSender(messageId);
// we close the right panel when switching conversation so the convoId of that message is always the selectedConversationKey
// is always the currently selected conversation
const convoId = useSelectedConversationKey();
const closePanel = useCallback(() => {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}, [dispatch]);
useKey('Escape', closePanel);
// close the panel if the messageInfo is associated with a deleted message
useEffect(() => {
if (!sender) {
closePanel();
}
}, [sender, closePanel]);
if (!rightOverlayMode || !messageInfo || !convoId || !messageId || !sender) {
return null;
}
const { params } = rightOverlayMode;
const visibleAttachmentIndex = params?.visibleAttachmentIndex || 0;
const { errors, attachments } = messageInfo;
const hasAttachments = attachments && attachments.length > 0;
const supportsAttachmentCarousel = canDisplayImage(attachments);
const hasErrors = errors && errors.length > 0;
const handleChangeAttachment = (changeDirection: 1 | -1) => {
if (!hasAttachments) {
return;
}
const newVisibleIndex = visibleAttachmentIndex + changeDirection;
if (newVisibleIndex > attachments.length - 1) {
return;
}
if (newVisibleIndex < 0) {
return;
}
if (attachments[newVisibleIndex]) {
dispatch(
setRightOverlayMode({
type: 'message_info',
params: { messageId, visibleAttachmentIndex: newVisibleIndex },
})
);
}
};
return (
<StyledScrollContainer>
<Flex container={true} flexDirection={'column'} alignItems={'center'}>
<Header hideBackButton={true} closeButtonOnClick={closePanel}>
<HeaderTitle>{window.i18n('messageInfo')}</HeaderTitle>
</Header>
<StyledMessageInfoContainer>
<MessageBody
messageId={messageId}
supportsAttachmentCarousel={supportsAttachmentCarousel}
/>
{hasAttachments && (
<>
{supportsAttachmentCarousel && (
<>
<AttachmentCarousel
messageId={messageId}
attachments={attachments}
visibleIndex={visibleAttachmentIndex}
nextAction={() => {
handleChangeAttachment(1);
}}
previousAction={() => {
handleChangeAttachment(-1);
}}
/>
<SpacerXL />
</>
)}
<AttachmentInfo attachment={attachments[visibleAttachmentIndex]} />
<SpacerMD />
</>
)}
<MessageInfo messageId={messageId} errors={messageInfo.errors} />
<SpacerLG />
<PanelButtonGroup style={{ margin: '0' }}>
<PanelIconButton
text={window.i18n('replyToMessage')}
iconType="reply"
onClick={() => {
// eslint-disable-next-line more/no-then
void replyToMessage(messageId).then(foundIt => {
if (foundIt) {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}
});
}}
dataTestId="reply-to-msg-from-details"
/>
{hasErrors && direction === 'outgoing' && (
<PanelIconButton
text={window.i18n('resend')}
iconType="resend"
onClick={() => {
void resendMessage(messageId);
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
}}
dataTestId="resend-msg-from-details"
/>
)}
{hasAttachments && (
<PanelIconButton
text={window.i18n('save')}
iconType="saveToDisk"
dataTestId="save-attachment-from-details"
onClick={() => {
if (hasAttachments) {
void saveAttachmentToDisk({
conversationId: convoId,
messageSender: sender,
messageTimestamp: serverTimestamp || timestamp || Date.now(),
attachment: attachments[visibleAttachmentIndex],
index: visibleAttachmentIndex,
});
}
}}
/>
)}
{isDeletable && (
<PanelIconButton
text={window.i18n('delete')}
iconType="delete"
color={'var(--danger-color)'}
dataTestId="delete-from-details"
onClick={() => {
void deleteMessagesById([messageId], convoId);
}}
/>
)}
</PanelButtonGroup>
<SpacerXL />
</StyledMessageInfoContainer>
</Flex>
</StyledScrollContainer>
);
};

@ -0,0 +1,141 @@
import { isEmpty } from 'lodash';
import React, { useCallback, useState } from 'react';
import styled, { CSSProperties } from 'styled-components';
import { PropsForAttachment } from '../../../../../../state/ducks/conversations';
import { getAlt, getThumbnailUrl, isVideoAttachment } from '../../../../../../types/Attachment';
import { Flex } from '../../../../../basic/Flex';
import { SessionIconButton } from '../../../../../icon';
import { Image } from '../../../../Image';
import {
StyledSubtitleDotMenu,
SubtitleDotMenu,
} from '../../../../header/ConversationHeaderSubtitle';
import { showLightboxFromAttachmentProps } from '../../../../message/message-content/MessageAttachment';
const CarouselButton = (props: { visible: boolean; rotation: number; onClick: () => void }) => {
return (
<SessionIconButton
iconSize={'huge'}
iconType={'chevron'}
iconRotation={props.rotation}
onClick={props.onClick}
iconPadding={'var(--margins-xs)'}
style={{
visibility: props.visible ? 'visible' : 'hidden',
}}
/>
);
};
const StyledFullscreenButton = styled.div``;
const FullscreenButton = (props: { onClick: () => void; style?: CSSProperties }) => {
return (
<StyledFullscreenButton style={props.style}>
<SessionIconButton
iconSize={'large'}
iconColor={'var(--button-icon-stroke-hover-color)'}
iconType={'fullscreen'}
onClick={props.onClick}
iconPadding={'6px'}
/>
</StyledFullscreenButton>
);
};
const ImageContainer = styled.div`
position: relative;
${StyledSubtitleDotMenu} {
position: absolute;
bottom: 8px;
left: 0;
right: 0;
margin: 0 auto;
z-index: 2;
}
${StyledFullscreenButton} {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 2;
}
`;
type Props = {
messageId: string;
attachments: Array<PropsForAttachment>;
visibleIndex: number;
nextAction: () => void;
previousAction: () => void;
};
export const AttachmentCarousel = (props: Props) => {
const { messageId, attachments, visibleIndex, nextAction, previousAction } = props;
const [imageBroken, setImageBroken] = useState(false);
const handleImageError = useCallback(() => {
setImageBroken(true);
}, [setImageBroken]);
if (isEmpty(attachments)) {
window.log.debug('No attachments to render in carousel');
return null;
}
const isVideo = isVideoAttachment(attachments[visibleIndex]);
const showLightbox = () => {
void showLightboxFromAttachmentProps(messageId, attachments[visibleIndex]);
};
if (imageBroken) {
return null;
}
return (
<Flex container={true} flexDirection={'row'} justifyContent={'center'} alignItems={'center'}>
<CarouselButton visible={visibleIndex > 0} onClick={previousAction} rotation={90} />
<ImageContainer>
<Image
alt={getAlt(attachments[visibleIndex])}
attachment={attachments[visibleIndex]}
playIconOverlay={isVideo}
height={'var(--right-panel-attachment-height)'}
width={'var(--right-panel-attachment-width)'}
url={getThumbnailUrl(attachments[visibleIndex])}
attachmentIndex={visibleIndex}
softCorners={true}
onClick={isVideo ? showLightbox : undefined}
onError={handleImageError}
/>
<SubtitleDotMenu
id={'attachment-carousel-subtitle-dots'}
selectedOptionIndex={visibleIndex}
optionsCount={attachments.length}
style={{
display: attachments.length < 2 ? 'none' : 'undefined',
padding: '6px',
backgroundColor: 'var(--modal-background-color)',
borderRadius: '50px',
width: 'fit-content',
}}
/>
<FullscreenButton
onClick={showLightbox}
style={{
backgroundColor: 'var(--modal-background-color)',
borderRadius: '50px',
}}
/>
</ImageContainer>
<CarouselButton
visible={visibleIndex < attachments.length - 1}
onClick={nextAction}
rotation={270}
/>
</Flex>
);
};

@ -0,0 +1,53 @@
import React from 'react';
import styled from 'styled-components';
import { LabelWithInfo } from '.';
import { PropsForAttachment } from '../../../../../../state/ducks/conversations';
import { Flex } from '../../../../../basic/Flex';
type Props = {
attachment: PropsForAttachment;
};
const StyledLabelContainer = styled(Flex)`
div {
// we want 2 items per row and that's the easiest to make it happen
min-width: 50%;
}
`;
export const AttachmentInfo = (props: Props) => {
const { attachment } = props;
return (
<Flex container={true} flexDirection="column">
<LabelWithInfo
label={`${window.i18n('fileId')}:`}
info={attachment?.id ? String(attachment.id) : window.i18n('notApplicable')}
/>
<StyledLabelContainer container={true} flexDirection="row" flexWrap="wrap">
<LabelWithInfo
label={`${window.i18n('fileType')}:`}
info={
attachment?.contentType ? String(attachment.contentType) : window.i18n('notApplicable')
}
/>
<LabelWithInfo
label={`${window.i18n('fileSize')}:`}
info={attachment?.fileSize ? String(attachment.fileSize) : window.i18n('notApplicable')}
/>
<LabelWithInfo
label={`${window.i18n('resolution')}:`}
info={
attachment?.width && attachment.height
? `${attachment.width}x${attachment.height}`
: window.i18n('notApplicable')
}
/>
<LabelWithInfo
label={`${window.i18n('duration')}:`}
info={attachment?.duration ? attachment?.duration : window.i18n('notApplicable')}
/>
</StyledLabelContainer>
</Flex>
);
};

@ -0,0 +1,50 @@
import React from 'react';
import styled from 'styled-components';
import { MessageInfoLabel } from '.';
import { useConversationUsername } from '../../../../../../hooks/useParamSelector';
import { Avatar, AvatarSize } from '../../../../../avatar/Avatar';
const StyledFromContainer = styled.div`
display: flex;
gap: var(--margins-lg);
align-items: center;
padding: var(--margins-xs) var(--margins-xs) var(--margins-xs) 0;
`;
const StyledAuthorNamesContainer = styled.div`
display: flex;
flex-direction: column;
`;
const Name = styled.span`
font-weight: bold;
`;
const Pubkey = styled.span`
font-family: var(--font-mono);
font-size: var(--font-size-md);
user-select: text;
`;
const StyledMessageInfoAuthor = styled.div`
font-size: var(--font-size-lg);
`;
export const MessageFrom = (props: { sender: string }) => {
const { sender } = props;
const profileName = useConversationUsername(sender);
const from = window.i18n('from');
return (
<StyledMessageInfoAuthor>
<MessageInfoLabel>{from}</MessageInfoLabel>
<StyledFromContainer>
<Avatar size={AvatarSize.M} pubkey={sender} onAvatarClick={undefined} />
<StyledAuthorNamesContainer>
{!!profileName && <Name>{profileName}</Name>}
<Pubkey>{sender}</Pubkey>
</StyledAuthorNamesContainer>
</StyledFromContainer>
</StyledMessageInfoAuthor>
);
};

@ -0,0 +1,155 @@
import { format, formatDistanceStrict } from 'date-fns';
import { ipcRenderer } from 'electron';
import { isEmpty } from 'lodash';
import moment from 'moment';
import React from 'react';
import styled from 'styled-components';
import { MessageFrom } from '.';
import {
useMessageDirection,
useMessageExpirationDurationMs,
useMessageExpirationTimestamp,
useMessageExpirationType,
useMessageHash,
useMessageReceivedAt,
useMessageSender,
useMessageServerId,
useMessageServerTimestamp,
useMessageTimestamp,
} from '../../../../../../state/selectors';
import { isDevProd } from '../../../../../../shared/env_vars';
import { Flex } from '../../../../../basic/Flex';
import { SpacerSM } from '../../../../../basic/Text';
export const MessageInfoLabel = styled.label<{ color?: string }>`
font-size: var(--font-size-lg);
font-weight: bold;
${props => props.color && `color: ${props.color};`}
`;
const MessageInfoData = styled.div<{ color?: string }>`
font-size: var(--font-size-md);
user-select: text;
${props => props.color && `color: ${props.color};`}
`;
const LabelWithInfoContainer = styled.div`
margin-bottom: var(--margins-md);
${props => props.onClick && 'cursor: pointer;'}
`;
type LabelWithInfoProps = {
label: string;
info: string;
labelColor?: string;
dataColor?: string;
title?: string;
onClick?: () => void;
};
export const LabelWithInfo = (props: LabelWithInfoProps) => {
return (
<LabelWithInfoContainer title={props.title || undefined} onClick={props.onClick}>
<MessageInfoLabel color={props.labelColor}>{props.label}</MessageInfoLabel>
<MessageInfoData color={props.dataColor}>{props.info}</MessageInfoData>
</LabelWithInfoContainer>
);
};
// Message timestamp format: "06:02 PM Tue, 15/11/2022"
const formatTimestamps = 'hh:mm A ddd, D/M/Y';
const showDebugLog = () => {
ipcRenderer.send('show-debug-log');
};
const DebugMessageInfo = ({ messageId }: { messageId: string }) => {
const messageHash = useMessageHash(messageId);
const serverId = useMessageServerId(messageId);
const expirationType = useMessageExpirationType(messageId);
const expirationDurationMs = useMessageExpirationDurationMs(messageId);
const expirationTimestamp = useMessageExpirationTimestamp(messageId);
if (!isDevProd()) {
return null;
}
return (
<>
{messageHash ? (
<LabelWithInfo label={`${window.i18n('messageHash')}:`} info={messageHash} />
) : null}
{serverId ? (
<LabelWithInfo label={`${window.i18n('serverId')}:`} info={`${serverId}`} />
) : null}
{expirationType ? (
<LabelWithInfo label={`${window.i18n('expirationType')}:`} info={expirationType} />
) : null}
{expirationDurationMs ? (
<LabelWithInfo
label={`${window.i18n('expirationDuration')}:`}
// formatDistanceStrict (date-fns) is not localized yet
info={`${formatDistanceStrict(0, Math.floor(expirationDurationMs / 1000))}`}
/>
) : null}
{expirationTimestamp ? (
<LabelWithInfo
label={`${window.i18n('disappears')}:`}
// format (date-fns) is not localized yet
info={`${format(expirationTimestamp, 'PPpp')}`}
/>
) : null}
</>
);
};
export const MessageInfo = ({ messageId, errors }: { messageId: string; errors: Array<Error> }) => {
const sender = useMessageSender(messageId);
const direction = useMessageDirection(messageId);
const sentAt = useMessageTimestamp(messageId);
const serverTimestamp = useMessageServerTimestamp(messageId);
const receivedAt = useMessageReceivedAt(messageId);
if (!messageId || !sender) {
return null;
}
const sentAtStr = `${moment(serverTimestamp || sentAt).format(formatTimestamps)}`;
const receivedAtStr = `${moment(receivedAt).format(formatTimestamps)}`;
const hasError = !isEmpty(errors);
const errorString = hasError
? errors?.reduce((previous, current, currentIndex) => {
return `${previous}${current.message}${
errors.length > 1 && currentIndex < errors.length - 1 ? ', ' : ''
}`;
}, '')
: null;
return (
<Flex container={true} flexDirection="column">
<LabelWithInfo label={`${window.i18n('sent')}:`} info={sentAtStr} />
<DebugMessageInfo messageId={messageId} />
{direction === 'incoming' ? (
<LabelWithInfo label={`${window.i18n('received')}:`} info={receivedAtStr} />
) : null}
<SpacerSM />
<MessageFrom sender={sender} />
{hasError && (
<>
<SpacerSM />
<LabelWithInfo
title={window.i18n('shareBugDetails')}
label={`${window.i18n('error')}:`}
info={errorString || window.i18n('unknownError')}
dataColor={'var(--danger-color)'}
onClick={showDebugLog}
/>
</>
)}
</Flex>
);
};

@ -0,0 +1,5 @@
import { AttachmentInfo } from './AttachmentInfo';
import { MessageFrom } from './MessageFrom';
import { LabelWithInfo, MessageInfo, MessageInfoLabel } from './MessageInfo';
export { AttachmentInfo, LabelWithInfo, MessageFrom, MessageInfo, MessageInfoLabel };

@ -1,61 +0,0 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { getConversationController } from '../../session/conversations';
import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SpacerLG } from '../basic/Text';
import { SessionSpinner } from '../basic/SessionSpinner';
const StyledWarning = styled.p`
max-width: 500px;
line-height: 1.3333;
`;
export const AdminLeaveClosedGroupDialog = (props: { conversationId: string }) => {
const dispatch = useDispatch();
const convo = getConversationController().get(props.conversationId);
const [loading, setLoading] = useState(false);
const titleText = `${window.i18n('leaveGroup')} ${convo?.getRealSessionUsername() || ''}`;
const closeDialog = () => {
dispatch(adminLeaveClosedGroup(null));
};
const onClickOK = async () => {
if (loading) {
return;
}
setLoading(true);
// we know want to delete a closed group right after we've left it, so we can call the deleteContact which takes care of it all
await getConversationController().deleteClosedGroup(props.conversationId, {
fromSyncMessage: false,
sendLeaveMessage: true,
});
setLoading(false);
closeDialog();
};
return (
<SessionWrapperModal title={titleText} onClose={closeDialog}>
<SpacerLG />
<StyledWarning>{window.i18n('leaveGroupConfirmationAdmin')}</StyledWarning>
<SessionSpinner loading={loading} />
<div className="session-modal__button-group">
<SessionButton
text={window.i18n('leaveAndRemoveForEveryone')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
onClick={onClickOK}
/>
<SessionButton
text={window.i18n('cancel')}
buttonType={SessionButtonType.Simple}
onClick={closeDialog}
/>
</div>
</SessionWrapperModal>
);
};

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save