Merge pull request #3016 from oxen-io/userconfig_disappearingmessage

Disappearing messages v2
pull/3019/head
Audric Ackermann 1 year ago committed by GitHub
commit ad9905d2ac
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',

@ -144,8 +144,11 @@ So you wanna make a pull request? Please observe the following guidelines.
- Never use plain strings right in the source code - pull them from `messages.json`!
You **only** need to modify the default locale
[`_locales/en/messages.json`](_locales/en/messages.json).
Other locales are generated automatically based on that file and then periodically
uploaded to Crowdin for translation.
Other locales are generated automatically based on that file and then periodically
uploaded to Crowdin for translation. If you add or change strings in messages.json
you will need to run [`tools/updateI18nKeysType.py`](tools/updateI18nKeysType.py)
this script generates updated TypeScript type definitions to ensure you aren't
using a localisation key which doesn't exist.
- Please do not submit pull requests for pure translation fixes. Anyone can update
the translations at [Crowdin](https://crowdin.com/project/session-desktop).
- [Rebase](https://nathanleclaire.com/blog/2014/09/14/dont-be-scared-of-git-rebase/) your

@ -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$",

@ -97,24 +97,37 @@
"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?",
"hideConversationFailed": "Failed to hide the Conversation!",
"hideConversationFailedPleaseTryAgain": "Unable to hide the conversation, please try again",
"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",
"hiding": "Hiding...",
"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",
"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 +194,18 @@
"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",
"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 +219,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 +232,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 +295,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 +370,7 @@
"editProfileModalTitle": "Profile",
"groupNamePlaceholder": "Group Name",
"inviteContacts": "Invite Contacts",
"addModerator": "Add Admin",
"addModerators": "Add Admins",
"removeModerators": "Remove Admins",
"addAsModerator": "Add as Admin",
@ -517,5 +563,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.11.6",
"license": "GPL-3.0",
"author": {
"name": "Oxen Labs",
@ -79,7 +79,7 @@
"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",
@ -95,7 +95,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",
@ -180,6 +180,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'});

@ -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 {

@ -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 {

@ -136,11 +136,12 @@ textarea {
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 +170,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 +603,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';
@ -52,7 +52,7 @@ const CrownWrapper = styled.div`
right: 12%;
height: 20px;
width: 20px;
transform: translate(25%, 25%);
transform: translate(20%, 20%); // getting over 23% creates a glitch
color: #f7c347;
background: var(--background-primary-color);
border-radius: 50%;

@ -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>
);
};

@ -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;
@ -46,7 +47,7 @@ export const Image = (props: Props) => {
attachment,
closeButton,
darkOverlay,
height,
height: _height,
onClick,
onClickClose,
onError,
@ -56,7 +57,7 @@ export const Image = (props: Props) => {
forceSquare,
attachmentIndex,
url,
width,
width: _width,
} = props;
const onErrorUrlFilterering = useCallback(() => {
@ -78,6 +79,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 +97,10 @@ 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,
}}
data-attachmentindex={attachmentIndex}
>
@ -104,11 +108,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 +127,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 +169,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}

@ -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,6 +70,7 @@ export interface LightBoxOptions {
}
interface Props {
ourDisplayNameInProfile: string;
ourNumber: string;
selectedConversationKey: string;
selectedConversation?: ReduxConversationType;
@ -233,9 +237,9 @@ export class SessionConversation extends React.Component<Props, State> {
const { isDraggingFile } = this.state;
const {
ourDisplayNameInProfile,
selectedConversation,
messagesProps,
showMessageDetails,
selectedMessages,
isRightPanelShowing,
lightBoxOptions,
@ -246,13 +250,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 +285,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 +311,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 +445,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 +571,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 +585,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>;
@ -51,16 +51,21 @@ type Props = SessionMessageListProps & {
scrollToNow: () => Promise<void>;
};
const StyledMessagesContainer = styled.div`
// isGroup is used to align the ExpireTimer with the member avatars
const StyledMessagesContainer = styled.div<{ isGroup: boolean }>`
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-right: var(--margins-lg);
padding-bottom: var(--margins-xl);
padding-left: ${props => (props.isGroup ? 'var(--margins-xs)' : 'var(--margins-lg)')};
.session-icon-button {
display: flex;
@ -72,6 +77,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;
@ -117,11 +126,12 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
<StyledMessagesContainer
className="messages-container"
id={messageContainerDomID}
isGroup={!conversation.isPrivate}
onScroll={this.handleScroll}
ref={this.props.messageContainerRef}
data-testid="messages-container"
>
<TypingBubble
<StyledTypingBubble
conversationType={conversation.type}
isTyping={!!conversation.isTyping}
key="typing-bubble"

@ -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,215 @@
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,
useSelectedIsNoteToSelf,
useSelectedIsPrivate,
useSelectedIsPrivateFriend,
} from '../../state/selectors/selectedConversation';
import { ReleasedFeatures } from '../../util/releaseFeature';
import { Flex } from '../basic/Flex';
import { 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';
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);
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' }}
>
<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,74 @@
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';
export 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>
);
};

@ -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="timer50"
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,
useSelectedIsGroup,
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 = useSelectedIsGroup();
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,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,7 @@ import {
showLightBox,
toggleSelectedMessageId,
} from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import {
getMessageAttachmentProps,
isMessageSelectionMode,
@ -66,7 +67,9 @@ 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 onClickOnImageGrid = useCallback(
@ -216,6 +219,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,6 +5,7 @@ import {
useAuthorName,
useAuthorProfileName,
useFirstMessageOfSeries,
useHideAvatarInMsgList,
useMessageAuthor,
useMessageDirection,
} from '../../../../state/selectors';
@ -19,9 +20,10 @@ 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) => {
@ -32,6 +34,7 @@ export const MessageAuthorText = (props: Props) => {
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<
@ -129,13 +131,13 @@ 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,
}}

@ -6,14 +6,21 @@ import { InView } from 'react-intersection-observer';
import { useSelector } from 'react-redux';
import styled, { css, keyframes } from 'styled-components';
import { MessageModelType, MessageRenderingProps } from '../../../../models/messageType';
import { useMessageIsDeleted } from '../../../../state/selectors';
import { StateType } from '../../../../state/reducer';
import { useHideAvatarInMsgList, useMessageIsDeleted } from '../../../../state/selectors';
import {
getMessageContentSelectorProps,
getQuotedMessageToAnimate,
getShouldHighlightMessage,
} from '../../../../state/selectors/conversations';
import {
useSelectedIsGroup,
useSelectedIsPrivate,
} from '../../../../state/selectors/selectedConversation';
import { canDisplayImage } from '../../../../types/Attachment';
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
import { MessageAttachment } from './MessageAttachment';
import { MessageAvatar } from './MessageAvatar';
import { MessageLinkPreview } from './MessageLinkPreview';
import { MessageQuote } from './MessageQuote';
import { MessageText } from './MessageText';
@ -45,7 +52,11 @@ function onClickOnMessageInnerContainer(event: React.MouseEvent<HTMLDivElement>)
}
}
const StyledMessageContent = styled.div``;
const StyledMessageContent = styled.div<{ msgDirection: MessageModelType }>`
display: flex;
align-self: ${props => (props.msgDirection === 'incoming' ? 'flex-start' : 'flex-end')};
`;
const opacityAnimation = keyframes`
0% {
@ -76,14 +87,14 @@ export const StyledMessageHighlighter = styled.div<{
`;
const StyledMessageOpaqueContent = styled(StyledMessageHighlighter)<{
messageDirection: MessageModelType;
isIncoming: boolean;
highlight: 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%;
@ -91,16 +102,25 @@ const StyledMessageOpaqueContent = styled(StyledMessageHighlighter)<{
export const IsMessageVisibleContext = createContext(false);
// NOTE aligns group member avatars with the ExpireTimer
const StyledAvatarContainer = styled.div<{ hideAvatar: boolean; isGroup: boolean }>`
/* margin-inline-start: ${props => (!props.hideAvatar && props.isGroup ? '-11px' : '')}; */
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 isGroup = useSelectedIsGroup();
const hideAvatar = useHideAvatarInMsgList(props.messageId);
const [imageBroken, setImageBroken] = useState(false);
@ -155,19 +175,39 @@ 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 hideAvatar={hideAvatar} isGroup={isGroup}>
<MessageAvatar
messageId={props.messageId}
hideAvatar={hideAvatar}
isPrivate={selectedIsPrivate}
/>
</StyledAvatarContainer>
<InView
id={`inview-content-${props.messageId}`}
onChange={onVisible}
@ -182,7 +222,7 @@ export const MessageContent = (props: Props) => {
>
<IsMessageVisibleContext.Provider value={isMessageVisible}>
{hasContentBeforeAttachment && (
<StyledMessageOpaqueContent messageDirection={direction} highlight={highlight}>
<StyledMessageOpaqueContent isIncoming={direction === 'incoming'} highlight={highlight}>
{!isDeleted && (
<>
<MessageQuote messageId={props.messageId} />
@ -195,7 +235,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}>
<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,7 +150,7 @@ export const MessageContentWithStatuses = (props: Props) => {
enableReactions={enableReactions}
/>
)}
</div>
</ExpirableReadableMessage>
{enableReactions && (
<MessageReactions
messageId={messageId}
@ -154,6 +160,7 @@ export const MessageContentWithStatuses = (props: Props) => {
setPopupReaction={setPopupReaction}
onPopupClick={handlePopupClick}
noAvatar={hideAvatar}
isDetailView={isDetailView}
/>
)}
</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 {
openRightPanel,
showMessageDetailsView,
toggleSelectedMessageId,
} from '../../../../state/ducks/conversations';
import { setRightOverlayMode } from '../../../../state/ducks/section';
import {
useMessageAttachments,
useMessageBody,
@ -162,6 +164,29 @@ 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) {
const messageDetailsProps = await found.getPropsForMessageDetail();
dispatch(showMessageDetailsView(messageDetailsProps));
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();
@ -214,16 +239,6 @@ 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');
@ -362,9 +377,13 @@ 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 ? (

@ -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;

@ -3,6 +3,7 @@ import React, { ReactElement, useEffect, useState } from 'react';
import styled from 'styled-components';
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
import { MessageRenderingProps } from '../../../../models/messageType';
import { REACT_LIMIT } from '../../../../session/constants';
import { useSelectedIsGroup } from '../../../../state/selectors/selectedConversation';
import { SortedReactionList } from '../../../../types/Reaction';
import { nativeEmojiData } from '../../../../util/emoji';
@ -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>([]);
@ -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,250 @@
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 { useSelectedIsGroup } 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;
};
export type MessageStatusSelectorProps = Pick<MessageRenderingProps, 'direction' | 'status'>;
/**
* 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 = (props: Props) => {
const { isCorrectSide, dataTestId } = props;
const direction = useMessageDirection(props.messageId);
const { messageId, isDetailView, dataTestId } = props;
const status = useMessageStatus(props.messageId);
const selected = useMessageExpirationPropsById(props.messageId);
if (!props.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`
color: var(--text-secondary-color);
font-size: small;
`;
const TextDetails = ({ text }: { text: string }) => {
return (
<>
<StyledStatusText>{text}</StyledStatusText>
<SpacerXS />
</>
);
};
if (!props.messageId) {
function IconDanger({ iconType }: { iconType: SessionIconType }) {
return <SessionIcon iconColor={'var(--danger-color'} iconType={iconType} iconSize="tiny" />;
}
function IconNormal({
iconType,
rotateDuration,
}: {
iconType: SessionIconType;
rotateDuration?: number | undefined;
}) {
return (
<SessionIcon
rotateDuration={rotateDuration}
iconColor={'var(--text-secondary-color)'}
iconType={iconType}
iconSize="tiny"
/>
);
}
function useIsExpiring(messageId: string) {
const selected = useMessageExpirationPropsById(messageId);
return (
selected && selected.expirationDurationMs && selected.expirationTimestamp && !selected.isExpired
);
}
function 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')} />
<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 = useSelectedIsGroup();
// 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')} />
<IconForExpiringMessageId messageId={messageId} iconType="circleCheck" />
</MessageStatusContainer>
);
};
const MessageStatusRead = ({
dataTestId,
messageId,
isIncoming,
}: Omit<Props, 'isDetailView'> & { isIncoming: boolean }) => {
const isExpiring = useIsExpiring(messageId);
const isGroup = useSelectedIsGroup();
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')} />
<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 = useSelectedIsGroup();
return (
<MessageStatusContainer
data-testid={dataTestId}
data-testtype="failed"
onClick={showDebugLog}
title={window.i18n('sendFailed')}
isIncoming={false}
isGroup={isGroup}
>
<TextDetails text={window.i18n('failed')} />
<IconDanger iconType="error" />
</MessageStatusContainer>
);
};

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

@ -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,11 +1,11 @@
import { isEmpty } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { useSelectedIsGroup } from '../../../../../state/selectors/selectedConversation';
import { MIME } from '../../../../../types';
import { GoogleChrome } from '../../../../../util';
import { MessageBody } from '../MessageBody';
import { QuoteProps } from './Quote';
import { useSelectedIsGroup } from '../../../../../state/selectors/selectedConversation';
const StyledQuoteText = styled.div<{ isIncoming: boolean }>`
display: -webkit-box;

@ -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 {
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;
@ -153,21 +81,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 = useSelector(state =>
getIsMessageSelected(state as any, props.messageId)
const isMessageSelected = useSelector((state: StateType) =>
getIsMessageSelected(state, props.messageId)
);
const multiSelectMode = useSelector(isMessageSelectionMode);
@ -228,58 +147,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')}
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,61 @@ 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;
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 +67,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 +76,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:
text = window.i18n('hideConversationFailedPleaseTryAgain');
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 (
@ -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) {
@ -151,7 +172,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
if (foundSentAt) {
getConversationController()
.get(selectedConversationKey)
?.markConversationRead(foundSentAt, Date.now());
?.markConversationRead({ newestUnreadDate: foundSentAt, fromConfigMessage: false });
}
}
}
@ -182,8 +203,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,24 @@
import React, { ReactElement, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useMouse } from 'react-use';
import styled from 'styled-components';
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { UserUtils } from '../../../../session/utils';
import { getRightOverlayMode } from '../../../../state/selectors/section';
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 +37,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 +56,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 +79,7 @@ export const Reaction = (props: ReactionProps): ReactElement => {
handlePopupClick,
} = props;
const rightOverlayMode = useSelector(getRightOverlayMode);
const isMessageSelection = useIsMessageSelectionMode();
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const senders = reactionsMap[emoji]?.senders || [];
@ -76,7 +87,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 +107,9 @@ export const Reaction = (props: ReactionProps): ReactElement => {
const handleReactionClick = () => {
if (!isMessageSelection) {
onClick(emoji);
if (onClick) {
onClick(emoji);
}
}
};
@ -107,12 +120,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,128 @@
import React from 'react';
import styled from 'styled-components';
import { useRightOverlayMode } from '../../../hooks/useUI';
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 = () => {
return (
<StyledRightPanel
container={true}
flexDirection={'column'}
alignItems={'center'}
width={'var(--right-panel-width)'}
height={'var(--right-panel-height)'}
className="right-panel"
>
<ClosableOverlay />
</StyledRightPanel>
);
};

@ -1,25 +1,30 @@
import { compact, flatten } from 'lodash';
import { compact, flatten, isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
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 { Data } from '../../../../data/data';
import { SessionIconButton } from '../../../icon';
import {
deleteAllMessagesByConvoIdWithConfirmation,
setDisappearingMessagesByConvoId,
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 { isRightPanelShowing } from '../../state/selectors/conversations';
} from '../../../../interactions/conversationInteractions';
import { Constants } from '../../../../session';
import { closeRightPanel } from '../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../state/ducks/section';
import {
useSelectedConversationKey,
useSelectedDisplayNameInProfile,
@ -29,18 +34,20 @@ import {
useSelectedIsKickedFromGroup,
useSelectedIsLeft,
useSelectedIsPublic,
useSelectedLastMessage,
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';
} 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 { SessionButton, SessionButtonColor, SessionButtonType } from '../../../basic/SessionButton';
import { SpacerMD } from '../../../basic/Text';
import { PanelButtonGroup, PanelIconButton } from '../../../buttons';
import { MediaItemType } from '../../../lightbox/LightboxGallery';
import { MediaGallery } from '../../media-gallery/MediaGallery';
import { Header } from './components';
async function getMediaGalleryProps(
conversationId: string
@ -117,44 +124,62 @@ async function getMediaGalleryProps(
const HeaderItem = () => {
const selectedConvoKey = useSelectedConversationKey();
const displayNameInProfile = useSelectedDisplayNameInProfile();
const dispatch = useDispatch();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroup();
const subscriberCount = useSelectedSubscriberCount();
if (!selectedConvoKey) {
return null;
}
const showInviteContacts = isGroup && !isKickedFromGroup && !isBlocked && !left;
const showMemberCount = !!(subscriberCount && subscriberCount > 0);
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"
/>
<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>
)}
</div>
</Header>
);
};
@ -177,65 +202,65 @@ const StyledLeaveButton = styled.div`
}
`;
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 = () => {
export const OverlayRightPanelSettings = () => {
const [documents, setDocuments] = useState<Array<MediaItemType>>([]);
const [media, setMedia] = useState<Array<MediaItemType>>([]);
const selectedConvoKey = useSelectedConversationKey();
const isShowing = useSelector(isRightPanelShowing);
const subscriberCount = useSelectedSubscriberCount();
const selectedUsername = useConversationUsername(selectedConvoKey) || selectedConvoKey;
const isShowing = useIsRightPanelShowing();
const dispatch = useDispatch();
const isActive = useSelectedIsActive();
const displayNameInProfile = useSelectedDisplayNameInProfile();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const left = useSelectedIsLeft();
const isGroup = useSelectedIsGroup();
const isPublic = useSelectedIsPublic();
const weAreAdmin = useSelectedWeAreAdmin();
const disappearingMessagesSubtitle = useDisappearingMessageSettingText({
convoId: selectedConvoKey,
separator: ': ',
});
const lastMessage = useSelectedLastMessage();
useEffect(() => {
let isRunning = true;
let isCancelled = false;
if (isShowing && selectedConvoKey) {
// eslint-disable-next-line more/no-then
void getMediaGalleryProps(selectedConvoKey).then(results => {
if (isRunning) {
setDocuments(results.documents);
setMedia(results.media);
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 () => {
isRunning = false;
isCancelled = true;
};
}, [isShowing, selectedConvoKey]);
}, [documents, isShowing, media, selectedConvoKey]);
useInterval(async () => {
if (isShowing && selectedConvoKey) {
@ -247,124 +272,104 @@ export const SessionRightPanelWithDetails = () => {
}
}, 10000);
const showMemberCount = !!(subscriberCount && subscriberCount > 0);
if (!selectedConvoKey) {
return null;
}
const commonNoShow = isKickedFromGroup || left || isBlocked || !isActive;
const hasDisappearingMessages = !isPublic && !commonNoShow;
const leaveGroupString = isPublic
? window.i18n('leaveGroup')
? 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 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 showUpdateGroupNameButton = isGroup && weAreAdmin && !commonNoShow; // legacy groups non-admin cannot change groupname anymore
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);
};
const deleteConvoAction = async () => {
await showLeaveGroupByConvoId(selectedConvoKey, selectedUsername);
};
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"
<PanelButtonGroup style={{ margin: '0 var(--margins-lg)' }}>
{showUpdateGroupNameButton && (
<PanelIconButton
iconType={'group'}
text={isPublic ? window.i18n('editGroup') : window.i18n('editGroupName')}
onClick={() => {
showAddModeratorsByConvoId(selectedConvoKey);
void showUpdateGroupNameByConvoId(selectedConvoKey);
}}
>
{window.i18n('addModerators')}
</StyledGroupSettingsItem>
<StyledGroupSettingsItem
className="group-settings-item"
role="button"
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={() => {
showRemoveModeratorsByConvoId(selectedConvoKey);
void showUpdateGroupMembersByConvoId(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}
dataTestId="group-members"
/>
</StyledLeaveButton>
)}
</div>
)}
{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 && (
<StyledLeaveButton>
<SessionButton
text={leaveGroupString}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
disabled={isKickedFromGroup || left}
onClick={deleteConvoAction}
/>
</StyledLeaveButton>
)}
</PanelButtonGroup>
</>
);
};

@ -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,226 @@
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,
useSelectedIsGroup,
useSelectedWeAreAdmin,
} from '../../../../../state/selectors/selectedConversation';
import { ReleasedFeatures } from '../../../../../util/releaseFeature';
import { Flex } from '../../../../basic/Flex';
import { SessionButton } from '../../../../basic/SessionButton';
import { SpacerLG, SpacerXL } from '../../../../basic/Text';
import { Header, HeaderSubtitle, HeaderTitle, StyledScrollContainer } from '../components';
import { DisappearingModes } from './DisappearingModes';
import { TimeOptions } from './TimeOptions';
const StyledContainer = styled(Flex)`
.session-button {
font-weight: 500;
min-width: 90px;
width: fit-content;
margin: 35px auto 0;
}
`;
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 = useSelectedIsGroup();
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>
<StyledContainer 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>
</>
)}
<SessionButton
onClick={handleSetMode}
disabled={
singleMode
? disappearingModeOptions[singleMode]
: modeSelected
? disappearingModeOptions[modeSelected]
: undefined
}
dataTestId={'disappear-set-button'}
>
{window.i18n('set')}
</SessionButton>
<SpacerLG />
<SpacerXL />
</StyledContainer>
</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,246 @@
import React 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 { closeMessageDetailsView, closeRightPanel } from '../../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../../state/ducks/section';
import { getMessageDetailsViewProps } from '../../../../../state/selectors/conversations';
import { Flex } from '../../../../basic/Flex';
import { Header, HeaderTitle, StyledScrollContainer } from '../components';
import {
replyToMessage,
resendMessage,
} from '../../../../../interactions/conversationInteractions';
import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions';
import {
useMessageIsDeletable,
useMessageQuote,
useMessageText,
} from '../../../../../state/selectors';
import { getRightOverlayMode } from '../../../../../state/selectors/section';
import { canDisplayImage } from '../../../../../types/Attachment';
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 StyledMessageDetailContainer = styled.div`
height: calc(100% - 48px);
width: 100%;
overflow-y: auto;
z-index: 2;
`;
const StyledMessageDetail = styled.div`
max-width: 650px;
margin-inline-start: auto;
margin-inline-end: auto;
padding: var(--margins-sm) var(--margins-2xl) var(--margins-lg);
`;
export const OverlayMessageInfo = () => {
const rightOverlayMode = useSelector(getRightOverlayMode);
const messageDetailProps = useSelector(getMessageDetailsViewProps);
const isDeletable = useMessageIsDeletable(messageDetailProps?.messageId);
const dispatch = useDispatch();
useKey('Escape', () => {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
dispatch(closeMessageDetailsView());
});
if (!rightOverlayMode || !messageDetailProps) {
return null;
}
const { params } = rightOverlayMode;
const visibleAttachmentIndex = params?.visibleAttachmentIndex || 0;
const {
convoId,
messageId,
sender,
attachments,
timestamp,
serverTimestamp,
errors,
direction,
} = messageDetailProps;
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={() => {
dispatch(closeRightPanel());
dispatch(resetRightOverlayMode());
dispatch(closeMessageDetailsView());
}}
>
<HeaderTitle>{window.i18n('messageInfo')}</HeaderTitle>
</Header>
<StyledMessageDetailContainer>
<StyledMessageDetail>
<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 />
<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 />
</StyledMessageDetail>
</StyledMessageDetailContainer>
</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,54 @@
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 {
flex-grow: 1;
}
`;
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" justifyContent="space-between">
<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')}
/>
</StyledLabelContainer>
<StyledLabelContainer container={true} flexDirection="row" justifyContent="space-between">
<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,97 @@
import { ipcRenderer } from 'electron';
import { isEmpty } from 'lodash';
import moment from 'moment';
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { MessageFrom } from '.';
import { getMessageDetailsViewProps } from '../../../../../../state/selectors/conversations';
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');
};
export const MessageInfo = () => {
const messageDetailProps = useSelector(getMessageDetailsViewProps);
if (!messageDetailProps) {
return null;
}
const { errors, receivedAt, sentAt, direction, sender } = messageDetailProps;
const sentAtStr = `${moment(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} />
{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>
);
};

@ -1,16 +1,18 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { ed25519Str } from '../../session/onions/onionPath';
import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI';
import { ed25519Str } from '../../session/onions/onionPath';
import { forceSyncConfigurationNowIfNeeded } from '../../session/utils/sync/syncUtils';
import { updateConfirmModal, updateDeleteAccountModal } from '../../state/ducks/modalDialog';
import { SpacerLG } from '../basic/Text';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionSpinner } from '../basic/SessionSpinner';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SpacerLG } from '../basic/Text';
import { Data } from '../../data/data';
import { deleteAllLogs } from '../../node/logs';
import { clearInbox } from '../../session/apis/open_group_api/sogsv3/sogsV3ClearInbox';
import { getAllValidOpenGroupV2ConversationRoomInfos } from '../../session/apis/open_group_api/utils/OpenGroupUtils';
import { SessionRadioGroup } from '../basic/SessionRadioGroup';
const deleteDbLocally = async () => {
@ -57,6 +59,25 @@ async function deleteEverythingAndNetworkData() {
// DELETE EVERYTHING ON NETWORK, AND THEN STUFF LOCALLY STORED
// a bit of duplicate code below, but it's easier to follow every case like that (helped with returns)
// clear all sogs inboxes (includes message requests)
const allRoomInfos = await getAllValidOpenGroupV2ConversationRoomInfos();
if (allRoomInfos && allRoomInfos.size > 0) {
// clear each inbox per sogs
// eslint-disable-next-line no-restricted-syntax
for (const roomInfo of allRoomInfos.values()) {
try {
// eslint-disable-next-line no-await-in-loop
const success = await clearInbox(roomInfo);
if (!success) {
throw Error(`Failed to clear inbox for ${roomInfo.conversationId}`);
}
} catch (error) {
window.log.info('DeleteAccount =>', error);
continue;
}
}
}
// send deletion message to the network
const potentiallyMaliciousSnodes = await SnodeAPI.forceNetworkDeletion();
if (potentiallyMaliciousSnodes === null) {
@ -192,6 +213,7 @@ export const DeleteAccountModal = () => {
}
}
};
const onDeleteEverythingAndNetworkData = async () => {
if (!isLoading) {
setIsLoading(true);

@ -114,7 +114,6 @@ const ProfileHeader = (props: ProfileHeaderProps): ReactElement => {
};
type ProfileDialogModes = 'default' | 'edit' | 'qr';
// tslint:disable-next-line: max-func-body-length
export const EditProfileDialog = (): ReactElement => {
const dispatch = useDispatch();
@ -211,7 +210,6 @@ export const EditProfileDialog = (): ReactElement => {
return (
/* The <div> element has a child <input> element that allows keyboard interaction */
/* tslint:disable-next-line: react-a11y-event-has-role */
<div className="edit-profile-dialog" data-testid="edit-profile-dialog" onKeyUp={handleOnKeyUp}>
<SessionWrapperModal
title={window.i18n('editProfileModalTitle')}

@ -2,7 +2,6 @@ import React from 'react';
import { useSelector } from 'react-redux';
import {
getAddModeratorsModal,
getAdminLeaveClosedGroupDialog,
getBanOrUnbanUserModalState,
getChangeNickNameDialog,
getConfirmModal,
@ -20,7 +19,6 @@ import {
getUpdateGroupNameModal,
getUserDetailsModal,
} from '../../state/selectors/modal';
import { AdminLeaveClosedGroupDialog } from './AdminLeaveClosedGroupDialog';
import { InviteContactsDialog } from './InviteContactsDialog';
import { DeleteAccountModal } from './DeleteAccountModal';
import { EditProfileDialog } from './EditProfileDialog';
@ -51,7 +49,6 @@ export const ModalContainer = () => {
const editProfileModalState = useSelector(getEditProfileDialog);
const onionPathModalState = useSelector(getOnionPathDialog);
const recoveryPhraseModalState = useSelector(getRecoveryPhraseDialog);
const adminLeaveClosedGroupModalState = useSelector(getAdminLeaveClosedGroupDialog);
const sessionPasswordModalState = useSelector(getSessionPasswordDialog);
const deleteAccountModalState = useSelector(getDeleteAccountModalState);
const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState);
@ -74,9 +71,6 @@ export const ModalContainer = () => {
{editProfileModalState && <EditProfileDialog {...editProfileModalState} />}
{onionPathModalState && <OnionPathModal {...onionPathModalState} />}
{recoveryPhraseModalState && <SessionSeedModal {...recoveryPhraseModalState} />}
{adminLeaveClosedGroupModalState && (
<AdminLeaveClosedGroupDialog {...adminLeaveClosedGroupModalState} />
)}
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
{confirmModalState && <SessionConfirm {...confirmModalState} />}

@ -1,27 +1,52 @@
import { shell } from 'electron';
import { Dispatch } from '@reduxjs/toolkit';
import React, { useState } from 'react';
import React, { Dispatch, useEffect, useState } from 'react';
import styled from 'styled-components';
import { useLastMessage } from '../../hooks/useParamSelector';
import { MessageInteraction } from '../../interactions';
import {
ConversationInteractionStatus,
updateConversationInteractionState,
} from '../../interactions/conversationInteractions';
import { updateConfirmModal } from '../../state/ducks/modalDialog';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
import { SessionRadioGroup, SessionRadioItems } from '../basic/SessionRadioGroup';
import { SessionSpinner } from '../basic/SessionSpinner';
import { SpacerLG } from '../basic/Text';
import { SessionIcon, SessionIconSize, SessionIconType } from '../icon';
const StyledSubText = styled(SessionHtmlRenderer)<{ textLength: number }>`
font-size: var(--font-size-md);
line-height: 1.5;
margin-bottom: var(--margins-lg);
max-width: ${props =>
props.textLength > 90
? '60ch'
: '33ch'}; // this is ugly, but we want the dialog description to have multiple lines when a short text is displayed
`;
const StyledSubMessageText = styled(SessionHtmlRenderer)`
// Overrides SASS in this one case
margin-top: 0;
margin-bottom: var(--margins - md);
`;
export interface SessionConfirmDialogProps {
message?: string;
messageSub?: string;
title?: string;
radioOptions?: SessionRadioItems;
onOk?: any;
onClose?: any;
closeAfterInput?: boolean;
/**
* function to run on ok click. Closes modal after execution by default
* sometimes the callback might need arguments when using radioOptions
*/
onClickOk?: () => Promise<void> | void;
onClickOk?: (...args: Array<any>) => Promise<void> | void;
onClickClose?: () => any;
@ -29,6 +54,7 @@ export interface SessionConfirmDialogProps {
* function to run on close click. Closes modal after execution by default
*/
onClickCancel?: () => any;
okText?: string;
cancelText?: string;
hideCancel?: boolean;
@ -38,6 +64,8 @@ export interface SessionConfirmDialogProps {
iconSize?: SessionIconSize;
shouldShowConfirm?: boolean | undefined;
showExitIcon?: boolean | undefined;
headerReverse?: boolean;
conversationId?: string;
}
export const SessionConfirm = (props: SessionConfirmDialogProps) => {
@ -45,6 +73,7 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
title = '',
message = '',
messageSub = '',
radioOptions,
okTheme,
closeTheme = SessionButtonColor.Danger,
onClickOk,
@ -55,22 +84,27 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
shouldShowConfirm,
onClickCancel,
showExitIcon,
headerReverse,
closeAfterInput = true,
conversationId,
} = props;
const lastMessage = useLastMessage(conversationId);
const [isLoading, setIsLoading] = useState(false);
const [chosenOption, setChosenOption] = useState(
radioOptions?.length ? radioOptions[0].value : ''
);
const okText = props.okText || window.i18n('ok');
const cancelText = props.cancelText || window.i18n('cancel');
const showHeader = !!props.title;
const messageSubText = messageSub ? 'session-confirm-main-message' : undefined;
const onClickOkHandler = async () => {
if (onClickOk) {
setIsLoading(true);
try {
await onClickOk();
await onClickOk(chosenOption !== '' ? chosenOption : undefined);
} catch (e) {
window.log.warn(e);
} finally {
@ -83,6 +117,18 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
}
};
useEffect(() => {
if (isLoading) {
if (conversationId && lastMessage?.interactionType) {
void updateConversationInteractionState({
conversationId,
type: lastMessage?.interactionType,
status: ConversationInteractionStatus.Loading,
});
}
}
}, [isLoading, conversationId, lastMessage?.interactionType]);
if (shouldShowConfirm && !shouldShowConfirm) {
return null;
}
@ -98,8 +144,6 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
if (onClickClose) {
onClickClose();
}
window.inboxStore?.dispatch(updateConfirmModal(null));
};
return (
@ -108,6 +152,7 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
onClose={onClickClose}
showExitIcon={showExitIcon}
showHeader={showHeader}
headerReverse={headerReverse}
>
{!showHeader && <SpacerLG />}
@ -119,8 +164,28 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
</>
)}
<SessionHtmlRenderer tag="span" className={messageSubText} html={message} />
<SessionHtmlRenderer tag="span" className="session-confirm-sub-message" html={messageSub} />
<StyledSubText tag="span" textLength={message.length} html={message} />
{messageSub && (
<StyledSubMessageText
tag="span"
className="session-confirm-sub-message"
html={messageSub}
/>
)}
{radioOptions && chosenOption !== '' ? (
<SessionRadioGroup
group="session-confirm-radio-group"
initialItem={chosenOption}
items={radioOptions}
radioPosition="right"
onClick={value => {
if (value) {
setChosenOption(value);
}
}}
/>
) : null}
<SessionSpinner loading={isLoading} />
</div>

@ -2,6 +2,7 @@
/* eslint-disable no-multi-str */
export type SessionIconType =
| 'addUser'
| 'addModerator'
| 'arrow'
| 'bell'
| 'brand'
@ -22,6 +23,7 @@ export type SessionIconType =
| 'crown'
| 'communities'
| 'delete'
| 'deleteModerator'
| 'ellipses'
| 'emoji'
| 'error'
@ -52,6 +54,9 @@ export type SessionIconType =
| 'plusThin'
| 'plusFat'
| 'reply'
| 'resend'
| 'saveToDisk'
| 'save'
| 'send'
| 'search'
| 'shield'
@ -60,7 +65,6 @@ export type SessionIconType =
| 'stopwatch'
| 'qr'
| 'users'
| 'upload'
| 'warning'
| 'sending'
| 'doubleCheckCircle'
@ -91,6 +95,12 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
viewBox: '0 0 25 21',
ratio: 1,
},
addModerator: {
path:
'M21.7.7H5.1c-1.2 0-2.2.5-3 1.3C1.3 2.8.9 3.8.9 5v10.5c0 1.1.4 2.2 1.2 3 .8.8 1.8 1.2 2.9 1.2h16.7c1.1 0 2.2-.5 2.9-1.2.8-.8 1.2-1.9 1.2-3V5c0-.6-.1-1.1-.3-1.6-.2-.5-.5-1-.9-1.4-.4-.4-.8-.7-1.4-.9-.4-.3-.9-.4-1.5-.4zm2.1 14.8c0 .6-.2 1.1-.6 1.5-.4.4-.9.6-1.5.6H5.1c-.6 0-1.1-.2-1.5-.6-.4-.4-.6-1-.6-1.5V5c0-.6.2-1.1.6-1.5.4-.4.9-.6 1.5-.6h16.7c.6 0 1.1.2 1.5.6.4.4.6.9.6 1.5-.1 0-.1 10.5-.1 10.5zM17.1 9.2h-2.7V6.5c0-.3-.1-.6-.3-.8-.4-.4-1.1-.4-1.5 0-.2.2-.3.5-.3.8v2.7H9.7c-.3 0-.5.1-.7.3-.4.4-.4 1.1 0 1.5.2.2.5.3.7.3h2.6V14c0 .3.1.6.3.8.2.2.5.3.7.3.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8v-2.7H17c.3 0 .5-.1.7-.3.4-.4.4-1.1 0-1.5-.1-.2-.4-.3-.6-.3z',
viewBox: '0 0 26 20',
ratio: 1.18,
},
arrow: {
path:
'M33.187,12.438 L6.097,12.438 L16.113,2.608 C16.704,2.027 16.713,1.078 16.133,0.486 C15.551,-0.105 14.602,-0.113 14.011,0.466 L1.407,12.836 C1.121,13.117 0.959,13.5 0.957981241,13.9 C0.956,14.3 1.114,14.685 1.397,14.968 L14.022,27.593 C14.315,27.886 14.699,28.032 15.083,28.032 C15.466,28.032 15.85,27.886 16.143,27.593 C16.729,27.007 16.729,26.057 16.143,25.472 L6.109,15.438 L33.187,15.438 C34.015,15.438 34.687,14.766 34.687,13.938 C34.687,13.109 34.015,12.438 33.187,12.438',
@ -167,10 +177,16 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
},
delete: {
path:
'M11.17 37.16h83.48a8.4 8.4 0 012 .16 5.93 5.93 0 012.88 1.56 5.43 5.43 0 011.64 3.34 7.65 7.65 0 01-.06 1.44L94 117.31V117.72a7.06 7.06 0 01-.2.9v.06a5.89 5.89 0 01-5.47 4.07H17.32a6.17 6.17 0 01-1.25-.19 6.17 6.17 0 01-1.16-.48 6.18 6.18 0 01-3.08-4.88l-7-73.49a7.69 7.69 0 01-.06-1.66 5.37 5.37 0 011.63-3.29 6 6 0 013-1.58 8.94 8.94 0 011.79-.13zM5.65 8.8h31.47V6a2.44 2.44 0 010-.27 6 6 0 011.76-4A6 6 0 0143.09 0h19.67a6 6 0 015.7 6v2.8h32.39a4.7 4.7 0 014.31 4.43v10.36a2.59 2.59 0 01-2.59 2.59H2.59A2.59 2.59 0 010 23.62V13.53a1.56 1.56 0 010-.31 4.72 4.72 0 013.88-4.34 10.4 10.4 0 011.77-.08zm42.1 52.7a4.77 4.77 0 019.49 0v37a4.77 4.77 0 01-9.49 0v-37zm23.73-.2a4.58 4.58 0 015-4.06 4.47 4.47 0 014.51 4.46l-2 37a4.57 4.57 0 01-5 4.06 4.47 4.47 0 01-4.51-4.46l2-37zM25 61.7a4.46 4.46 0 014.5-4.46 4.58 4.58 0 015 4.06l2 37a4.47 4.47 0 01-4.51 4.46 4.57 4.57 0 01-5-4.06l-2-37z',
viewBox: '0 0 105.16 122.88',
'M8.28716 24.5373H18.7049C20.724 24.5373 21.6111 23.5401 21.9231 21.534L23.5096 5.93218L21.6088 6.01432L20.0303 21.4227C19.8884 22.3331 19.4284 22.6989 18.6145 22.6989H8.38546C7.55548 22.6989 7.1036 22.3331 6.96978 21.4227L5.39126 6.01432L3.49037 5.93218L5.07693 21.534C5.38087 23.5482 6.27599 24.5373 8.28716 24.5373ZM3.60064 6.95776H23.4011C24.7332 6.95776 25.5 6.10588 25.5 4.78464V3.35104C25.5 2.03156 24.7332 1.17969 23.4011 1.17969H3.60064C2.32036 1.17969 1.5 2.03156 1.5 3.35104V4.78464C1.5 6.10588 2.27032 6.95776 3.60064 6.95776ZM3.97815 5.20931C3.52823 5.20931 3.33202 5.00521 3.33202 4.55387V3.58181C3.33202 3.13048 3.52823 2.92638 3.97815 2.92638H23.0299C23.4798 2.92638 23.668 3.13048 23.668 3.58181V4.55387C23.668 5.00521 23.4798 5.20931 23.0299 5.20931H3.97815Z',
viewBox: '0 0 26 26',
ratio: 1,
},
deleteModerator: {
path:
'M21.7.7H5.1c-1.2 0-2.2.5-3 1.3C1.3 2.8.9 3.8.9 5v10.5c0 1.1.4 2.2 1.2 3 .8.8 1.8 1.2 2.9 1.2h16.7c1.1 0 2.2-.5 2.9-1.2.8-.8 1.2-1.9 1.2-3V5c0-.6-.1-1.1-.3-1.6-.2-.5-.5-1-.9-1.4-.4-.4-.8-.7-1.4-.9-.4-.3-.9-.4-1.5-.4zm2.1 14.8c0 .6-.2 1.1-.6 1.5-.4.4-.9.6-1.5.6H5.1c-.6 0-1.1-.2-1.5-.6-.4-.4-.6-1-.6-1.5V5c0-.6.2-1.1.6-1.5.4-.4.9-.6 1.5-.6h16.7c.6 0 1.1.2 1.5.6.4.4.6.9.6 1.5-.1 0-.1 10.5-.1 10.5zM14.4 11.3h2.7c.3 0 .5-.1.7-.3.4-.4.4-1.1 0-1.5-.2-.2-.5-.3-.7-.3H9.7c-.3 0-.5.1-.7.3-.4.4-.4 1.1 0 1.5.2.2.5.3.7.3h4.7z',
viewBox: '0 0 26 20',
ratio: 1.18,
},
doubleCheckCircleFilled: {
path:
'M7.91731278,0.313257194 C6.15053376,1.58392424 5,3.65760134 5,6 C5,6.343797 5.0247846,6.68180525 5.07266453,7.01233547 L5,7.085 L3.205,5.295 L2.5,6 L5,8.5 L5.33970233,8.16029767 C5.80439817,9.59399486 6.71914823,10.8250231 7.91731278,11.6867428 C7.31518343,11.8898758 6.67037399,12 6,12 C2.688,12 0,9.312 0,6 C0,2.688 2.688,0 6,0 C6.67037399,0 7.31518343,0.110124239 7.91731278,0.313257194 Z M12,0 C15.312,0 18,2.688 18,6 C18,9.312 15.312,12 12,12 C8.688,12 6,9.312 6,6 C6,2.688 8.688,0 12,0 Z M11,8.5 L15.5,4 L14.795,3.29 L11,7.085 L9.205,5.295 L8.5,6 L11,8.5 Z',
@ -209,9 +225,9 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
},
group: {
path:
'M19.0674 11.1167C22.141 11.1167 24.647 8.6258 24.647 5.55835C24.647 2.4909 22.1466 0 19.0674 0C15.9882 0 13.4878 2.4909 13.4878 5.55835C13.4878 8.6258 15.9882 11.1167 19.0674 11.1167ZM19.0674 2.24461C20.9048 2.24461 22.3994 3.73355 22.3994 5.56395C22.3994 7.39434 20.9048 8.88329 19.0674 8.88329C17.23 8.88329 15.7354 7.39434 15.7354 5.56395C15.7354 3.73355 17.23 2.24461 19.0674 2.24461ZM19.0674 12.9415C14.7015 12.9415 11.0098 15.2757 9.82422 18.4719C9.5489 19.2108 10.0771 20 10.8693 20C11.3582 20 11.7684 19.681 11.9369 19.222C12.7966 16.8934 15.6623 15.1805 19.0674 15.1805C22.4724 15.1805 25.3381 16.899 26.1978 19.222C26.3664 19.6754 26.7766 20 27.2654 20C28.0577 20 28.5858 19.2108 28.3105 18.4719C27.1249 15.2757 23.4333 12.9415 19.0674 12.9415ZM30.5581 10.8928C33.0136 10.8928 35.0139 8.90009 35.0139 6.44836C35.0139 3.99664 33.0136 2.00952 30.5581 2.00952C28.1026 2.00952 26.1023 4.00224 26.1023 6.44836C26.1023 8.89449 28.1026 10.8928 30.5581 10.8928ZM30.5581 3.80073C32.0247 3.80073 33.2215 4.9874 33.2215 6.45396C33.2215 7.92051 32.0303 9.1072 30.5581 9.1072C29.086 9.1072 27.8947 7.91492 27.8947 6.45396C27.8947 4.993 29.086 3.80073 30.5581 3.80073ZM7.45314 10.8928C9.90861 10.8928 11.9089 8.90009 11.9089 6.44836C11.9089 3.99664 9.90861 2.00952 7.45314 2.00952C4.99766 2.00952 2.99731 4.00224 2.99731 6.44836C2.99731 8.89449 4.99766 10.8928 7.45314 10.8928ZM7.45314 3.80073C8.91968 3.80073 10.1165 4.9874 10.1165 6.45396C10.1165 7.92051 8.9253 9.1072 7.45314 9.1072C5.98097 9.1072 4.78976 7.91492 4.78976 6.45396C4.78976 4.993 5.98097 3.80073 7.45314 3.80073ZM37.9415 16.7702C36.9919 14.2177 34.042 12.3481 30.5526 12.3481C28.9681 12.3481 27.4959 12.7344 26.271 13.3949C26.8217 13.7699 27.333 14.1897 27.7881 14.6487C28.6141 14.3241 29.5525 14.1338 30.5526 14.1338C33.2722 14.1338 35.5647 15.5052 36.2502 17.3635C36.3851 17.7274 36.711 17.9849 37.1043 17.9849C37.7392 17.9849 38.1607 17.3523 37.9415 16.7646V16.7702ZM11.8021 13.4397C10.5604 12.7568 9.06572 12.3538 7.44747 12.3538C3.95811 12.3538 1.00817 14.2233 0.0585709 16.7758C-0.160568 17.3691 0.260847 17.9961 0.895787 17.9961C1.28349 17.9961 1.61501 17.7386 1.74987 17.3748C2.43538 15.5164 4.7279 14.145 7.44747 14.145C8.48697 14.145 9.45904 14.3465 10.3075 14.6935C10.757 14.2345 11.2571 13.8203 11.8021 13.4397Z',
viewBox: '0 0 38 20',
ratio: 1.5,
'M10.39 12.447a6.35 6.35 0 0 0 3.468-1.027 6.128 6.128 0 0 0 2.298-2.736 5.96 5.96 0 0 0 .355-3.521 6.051 6.051 0 0 0-1.71-3.12A6.29 6.29 0 0 0 11.606.375a6.382 6.382 0 0 0-3.607.348 6.211 6.211 0 0 0-2.8 2.246 5.992 5.992 0 0 0-1.05 3.387 6.03 6.03 0 0 0 1.832 4.304 6.327 6.327 0 0 0 4.41 1.786Zm0-10.568c.907 0 1.793.263 2.547.755a4.503 4.503 0 0 1 1.69 2.01 4.38 4.38 0 0 1 .26 2.587 4.447 4.447 0 0 1-1.255 2.292 4.62 4.62 0 0 1-2.348 1.226 4.69 4.69 0 0 1-2.65-.255 4.563 4.563 0 0 1-2.057-1.65 4.403 4.403 0 0 1-.773-2.487 4.429 4.429 0 0 1 1.344-3.165A4.647 4.647 0 0 1 10.39 1.88ZM13.792 13.975H6.83c-.837 0-1.665.16-2.438.474a6.375 6.375 0 0 0-2.066 1.35A6.204 6.204 0 0 0 .948 17.82a6.084 6.084 0 0 0-.48 2.382v1.616a.44.44 0 0 0 .135.318.462.462 0 0 0 .325.131h.741a.465.465 0 0 0 .322-.133.443.443 0 0 0 .133-.316v-1.616a4.54 4.54 0 0 1 1.379-3.249 4.764 4.764 0 0 1 3.327-1.346h6.962c1.248 0 2.445.484 3.327 1.346a4.54 4.54 0 0 1 1.378 3.249v1.426c0 .119.048.232.134.316a.465.465 0 0 0 .322.133h.74a.465.465 0 0 0 .325-.131.443.443 0 0 0 .135-.318v-1.426a6.084 6.084 0 0 0-.48-2.382 6.203 6.203 0 0 0-1.377-2.02 6.375 6.375 0 0 0-2.066-1.351 6.496 6.496 0 0 0-2.438-.474ZM16.713 1.754a.358.358 0 0 0 .26.138 4.628 4.628 0 0 1 3.06 1.398c.801.83 1.247 1.927 1.247 3.067 0 1.14-.446 2.237-1.246 3.067-.8.83-1.895 1.33-3.06 1.398a.35.35 0 0 0-.261.134 8.159 8.159 0 0 1-.77.869.345.345 0 0 0-.088.37.348.348 0 0 0 .12.16c.056.04.123.063.191.068a6.204 6.204 0 0 0 2.6-.323 6.206 6.206 0 0 0 3.31-2.657 5.968 5.968 0 0 0 .773-4.115 6.058 6.058 0 0 0-2.128-3.63A6.328 6.328 0 0 0 16.15.287a.361.361 0 0 0-.19.07.348.348 0 0 0-.13.356c.016.065.05.125.1.171.282.271.544.562.783.87ZM20.112 13.975h-.327a.422.422 0 0 0-.251.055.396.396 0 0 0-.055.656c.243.24.471.496.683.764a.41.41 0 0 0 .306.161 4.742 4.742 0 0 1 3.088 1.466 4.527 4.527 0 0 1 1.241 3.125v1.426c0 .12.049.233.135.318a.465.465 0 0 0 .325.131h.74a.465.465 0 0 0 .323-.133.443.443 0 0 0 .133-.316v-1.426a6.079 6.079 0 0 0-.476-2.378 6.2 6.2 0 0 0-1.373-2.019 6.368 6.368 0 0 0-2.06-1.351 6.489 6.489 0 0 0-2.432-.48Z',
viewBox: '0 0 27 23',
ratio: 1.17,
},
ellipses: {
path:
@ -251,8 +267,8 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
},
fullscreen: {
path:
'M205.801,122.042c-22.778,0-45.56,0-68.334,0c-6.081,0-11.301,5-11.301,11.14 c0.004,22.774,0.007,45.552,0.007,68.326c0.004,14.487,22.445,14.614,22.445,0.161c-0.004-13.777-0.004-27.55-0.004-41.326 c16.136,16.136,32.277,32.276,48.413,48.409c10.224,10.224,26.035-5.703,15.785-15.953c-16.11-16.11-32.217-32.213-48.323-48.319 c13.717,0,27.437,0,41.154,0C220.128,144.487,220.255,122.042,205.801,122.042zM323.064,261.753c0.004,13.777,0.004,27.546,0.004,41.323 c-16.136-16.136-32.276-32.276-48.413-48.413c-10.224-10.224-26.035,5.699-15.785,15.953c16.11,16.11,32.213,32.213,48.323,48.323 c-13.721,0-27.437,0.004-41.154,0.004c-14.487,0.004-14.614,22.445-0.161,22.445c22.778-0.004,45.56-0.007,68.334-0.007 c6.081-0.004,11.301-5,11.301-11.14c-0.004-22.774-0.007-45.548-0.007-68.323C345.506,247.427,323.064,247.3,323.064,261.753zM265.882,144.494c13.777-0.004,27.546-0.004,41.323-0.004 c-16.136,16.133-32.276,32.273-48.413,48.405c-10.224,10.224,5.699,26.035,15.953,15.785c16.11-16.106,32.213-32.209,48.323-48.316 c0,13.713,0.004,27.43,0.004,41.147c0.004,14.487,22.445,14.614,22.445,0.161c-0.004-22.774-0.007-45.552-0.007-68.326 c0-6.081-5-11.301-11.14-11.301c-22.774,0.004-45.548,0.007-68.323,0.007C251.556,122.053,251.428,144.494,265.882,144.494zM205.801,318.932c-13.777,0.004-27.55,0.004-41.323,0.004 c16.133-16.133,32.273-32.273,48.405-48.405c10.224-10.224-5.699-26.035-15.953-15.785c-16.11,16.106-32.213,32.213-48.319,48.319 c0-13.717,0-27.434,0-41.151c0-14.487-22.445-14.614-22.445-0.161c0,22.774,0,45.552,0,68.326c0,6.081,5,11.301,11.14,11.301 c22.778-0.004,45.552-0.007,68.326-0.007C220.128,341.373,220.255,318.932,205.801,318.932z',
viewBox: '80 80 310 310',
'M1.58658 5.92245C2.01016 5.92245 2.31996 5.6113 2.31996 5.18773V4.70291L2.17962 2.57199L3.76098 4.23964L5.7154 6.20761C5.85248 6.35146 6.03318 6.41781 6.22865 6.41781C6.68419 6.41781 7.01132 6.11115 7.01132 5.65994C7.01132 5.45147 6.93451 5.26698 6.79187 5.12434L4.82948 3.1687L3.16061 1.58856L5.3044 1.72889H5.82145C6.24503 1.72889 6.56173 1.42465 6.56173 0.996737C6.56173 0.566388 6.25059 0.257812 5.82145 0.257812H2.06855C1.29481 0.257812 0.847656 0.704963 0.847656 1.47748V5.18773C0.847656 5.60575 1.163 5.92245 1.58658 5.92245ZM8.9068 13.3075H12.6609C13.4334 13.3075 13.8862 12.8603 13.8862 12.0866V8.37629C13.8862 7.95952 13.5708 7.6428 13.1417 7.6428C12.7237 7.6428 12.4083 7.95397 12.4083 8.37629V8.86235L12.5542 10.992L10.9673 9.32438L9.0184 7.35763C8.88137 7.21378 8.69509 7.14743 8.49962 7.14743C8.04962 7.14743 7.71695 7.4541 7.71695 7.9041C7.71695 8.11257 7.79931 8.29827 7.94195 8.43969L9.89881 10.3953L11.5732 11.9755L9.42945 11.8352H8.9068C8.48448 11.8352 8.16655 12.1394 8.16655 12.5685C8.16655 12.9989 8.48448 13.3075 8.9068 13.3075Z',
viewBox: '0 0 14 14',
ratio: 1,
},
gear: {
@ -388,8 +404,26 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
},
reply: {
path:
'M4,3 C4.55228475,3 5,3.44771525 5,4 L5,4 L5,11 C5,12.6568542 6.34314575,14 8,14 L8,14 L17.585,14 L14.2928932,10.7071068 C13.9324093,10.3466228 13.9046797,9.77939176 14.2097046,9.38710056 L14.2928932,9.29289322 C14.6834175,8.90236893 15.3165825,8.90236893 15.7071068,9.29289322 L15.7071068,9.29289322 L20.7071068,14.2928932 C20.7355731,14.3213595 20.7623312,14.3515341 20.787214,14.3832499 C20.788658,14.3849951 20.7902348,14.3870172 20.7918027,14.389044 C20.8140715,14.4179625 20.8348358,14.4480862 20.8539326,14.4793398 C20.8613931,14.4913869 20.8685012,14.5036056 20.8753288,14.5159379 C20.8862061,14.5357061 20.8966234,14.5561086 20.9063462,14.5769009 C20.914321,14.5939015 20.9218036,14.6112044 20.9287745,14.628664 C20.9366843,14.6484208 20.9438775,14.6682023 20.9504533,14.6882636 C20.9552713,14.7031487 20.9599023,14.7185367 20.9641549,14.734007 C20.9701664,14.7555635 20.9753602,14.7772539 20.9798348,14.7992059 C20.9832978,14.8166247 20.9863719,14.834051 20.9889822,14.8515331 C20.9962388,14.8996379 21,14.9493797 21,15 L20.9962979,14.9137692 C20.9978436,14.9317345 20.9989053,14.9497336 20.9994829,14.9677454 L21,15 C21,15.0112225 20.9998151,15.0224019 20.9994483,15.0335352 C20.9988772,15.050591 20.997855,15.0679231 20.996384,15.0852242 C20.994564,15.1070574 20.9920941,15.1281144 20.9889807,15.1489612 C20.9863719,15.165949 20.9832978,15.1833753 20.9797599,15.2007258 C20.9753602,15.2227461 20.9701664,15.2444365 20.964279,15.2658396 C20.9599023,15.2814633 20.9552713,15.2968513 20.9502619,15.3121425 C20.9438775,15.3317977 20.9366843,15.3515792 20.928896,15.3710585 C20.9218036,15.3887956 20.914321,15.4060985 20.9063266,15.4232215 C20.8974314,15.4421635 20.8879327,15.4609002 20.8778732,15.4792864 C20.8703855,15.4931447 20.862375,15.5070057 20.8540045,15.5207088 C20.8382813,15.546275 20.8215099,15.5711307 20.8036865,15.5951593 C20.774687,15.6343256 20.7425008,15.6717127 20.7071068,15.7071068 L20.787214,15.6167501 C20.7849289,15.6196628 20.7826279,15.6225624 20.7803112,15.625449 L20.7071068,15.7071068 L15.7071068,20.7071068 C15.3165825,21.0976311 14.6834175,21.0976311 14.2928932,20.7071068 C13.9023689,20.3165825 13.9023689,19.6834175 14.2928932,19.2928932 L14.2928932,19.2928932 L17.585,16 L8,16 C5.3112453,16 3.11818189,13.8776933 3.00461951,11.2168896 L3,11 L3,4 C3,3.44771525 3.44771525,3 4,3',
viewBox: '-0.5 0.3 23 22',
'M10.3866 3.67969V8.08093C19.9715 8.81448 23.0606 17.026 23.407 21.0401C19.2992 14.9762 13.0151 14.5198 10.3866 15.0496V19.5731L2.50098 11.8098L10.3866 3.67969Z',
viewBox: '0 0 26 26',
ratio: 1,
},
resend: {
path:
'M13.4676 20.291C15.6689 20.291 17.7946 19.5483 19.3069 18.3295C20.2204 17.6274 20.4142 16.6462 19.8212 15.9426C19.2067 15.2292 18.3395 15.2238 17.5257 15.7939C16.2964 16.745 15.0736 17.2348 13.4676 17.2348C10.1089 17.2348 7.3268 14.9998 6.532 12.0107H8.26073C9.13784 12.0107 9.37752 11.1344 8.86794 10.4512L5.96015 6.43365C5.44875 5.74004 4.58148 5.69501 4.05514 6.43365L1.18378 10.4512C0.674203 11.1496 0.898943 12.0107 1.77604 12.0107H3.55576C4.43505 16.8892 8.43121 20.291 13.4676 20.291ZM13.4378 0.291016C11.2549 0.291016 9.11089 1.01868 7.61675 2.23754C6.69994 2.93957 6.50944 3.92089 7.10243 4.62439C7.71691 5.33776 8.58419 5.33996 9.37969 4.77298C10.6122 3.83705 11.835 3.33231 13.4378 3.33231C16.7998 3.33231 19.5786 5.56727 20.3917 8.55621H18.5701C17.678 8.55621 17.45 9.43248 17.9478 10.1159L20.8707 14.1334C21.382 14.8269 22.2493 14.8721 22.7755 14.1334L25.6469 10.1159C26.1565 9.43248 25.9318 8.55621 25.0397 8.55621H23.3496C22.4854 3.67456 18.4924 0.291016 13.4378 0.291016Z',
viewBox: '0 0 26 21',
ratio: 1,
},
saveToDisk: {
path:
'M11.134 21.796c5.846 0 10.673-4.834 10.673-10.671 0-5.846-4.835-10.67-10.681-10.67C5.29.454.464 5.278.464 11.124c0 5.837 4.835 10.67 10.67 10.67Zm0-2.102a8.533 8.533 0 0 1-8.56-8.57 8.526 8.526 0 0 1 8.552-8.568 8.549 8.549 0 0 1 8.579 8.569 8.542 8.542 0 0 1-8.57 8.569ZM11.117 14.28a.838.838 0 0 0 .578-.255l3.422-3.426a.768.768 0 0 0 .243-.575c0-.43-.305-.767-.73-.767a.805.805 0 0 0-.576.24l-.643.657-1.633 1.85.117-1.763V5.936a.754.754 0 0 0-.777-.782c-.466 0-.778.326-.778.782v4.305l.117 1.772-1.659-1.867-.6-.65a.716.716 0 0 0-.563-.24c-.436 0-.742.322-.742.768 0 .184.09.416.237.567l3.41 3.434a.807.807 0 0 0 .578.256Zm-3.764 2.254h7.557c.44 0 .758-.327.758-.775A.737.737 0 0 0 14.91 15H7.353a.744.744 0 0 0-.767.758c0 .448.326.775.767.775Z',
viewBox: '0 0 22 22',
ratio: 1,
},
save: {
path:
'M24.5771 6C23.4412 6 22.624 6.79418 22.624 7.93005V29.897L22.7949 34.5813L16.238 27.3968L11.3587 22.5957C11.0122 22.2492 10.4996 22.0506 9.98225 22.0506C8.86942 22.0506 8.08892 22.8725 8.08892 23.9623C8.08892 24.4937 8.30166 24.9602 8.72674 25.4267L23.1089 39.8409C23.5382 40.2702 24.0461 40.4919 24.5771 40.4919C25.1128 40.4919 25.6208 40.2702 26.0271 39.8409L40.4139 25.4267C40.862 24.9602 41.0748 24.4937 41.0748 23.9623C41.0748 22.8725 40.2712 22.0506 39.1815 22.0506C38.6641 22.0506 38.1515 22.2492 37.782 22.5957L32.898 27.3968L26.3641 34.5582L26.5119 29.897V7.93005C26.5119 6.79418 25.7178 6 24.5771 6ZM9.88864 40.4364C8.77109 40.4364 8 41.2583 8 42.3942C8 43.5301 8.77109 44.3473 9.88864 44.3473H39.2242C40.3648 44.3473 41.1359 43.5301 41.1359 42.3942C41.1359 41.2583 40.3648 40.4364 39.2242 40.4364H9.88864Z',
viewBox: '0 0 50 50',
ratio: 1,
},
search: {
@ -418,8 +452,8 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
},
stopwatch: {
path:
'm282.523438 1.34375c-8.800782-.886719-17.640626-1.328125-26.484376-1.3242188h-.265624c-14.636719.2421878-26.339844 12.2421878-26.214844 26.8828128v105.53125c-.035156 3.554687.6875 7.074218 2.117187 10.328125 4.582031 10.90625 15.863281 17.429687 27.597657 15.964843 13.554687-2.03125 23.5-13.792968 23.25-27.492187v-76.484375c98.890624 12.980469 173.726562 95.839844 176.59375 195.539062 2.867187 99.699219-67.078126 186.726563-165.058594 205.367188-97.980469 18.644531-195-36.613281-228.945313-130.398438-33.945312-93.785156 5.226563-198.339843 92.441407-246.730468 11.59375-6.566406 16.445312-20.769532 11.289062-33.054688l-.03125-.074218c-2.890625-6.988282-8.625-12.40625-15.765625-14.902344-7.136719-2.492188-15-1.820313-21.613281 1.84375-110.347656 61.484375-159.351563 194.269531-115.402344 312.695312 43.945312 118.429688 167.710938 187.097656 291.453125 161.710938 123.742187-25.386719 210.472656-137.238282 204.238281-263.40625-6.230468-126.164063-103.558594-228.925782-229.199218-241.996094zm0 0 M159.300781 170.949219c10.652344 28.050781 45.503907 94.28125 71.574219 122.480469 16.027344 18.09375 43.394531 20.515624 62.351562 5.523437 9.484376-7.957031 15.191407-19.527344 15.738282-31.894531.542968-12.363282-4.132813-24.390625-12.878906-33.148438-27.265626-27.261718-96.464844-63.382812-125.480469-74.398437-3.25-1.222657-6.917969-.417969-9.363281 2.050781-2.441407 2.472656-3.203126 6.148438-1.941407 9.386719zm0 0',
viewBox: '0 0 512 512',
'M3.72595 5.64444C4.38882 6.30732 5.06159 6.93556 5.68489 7.51434C6.32303 8.09312 7.01064 8.71147 7.71309 9.30509C8.5392 10.0075 9.19219 10.527 9.83527 10.9969C10.043 11.1503 10.2904 11.2294 10.5476 11.2294C10.8741 11.2294 11.1956 11.0958 11.4232 10.8584C11.8882 10.3786 11.8734 9.60685 11.3935 9.14184C10.8197 8.59275 10.1964 8.0387 9.36038 7.34615C8.65298 6.75748 7.9258 6.18859 7.24808 5.65928C6.56542 5.14481 5.83329 4.59077 5.06159 4.05156C4.17116 3.42332 3.46871 2.95832 2.78605 2.54773C2.67227 2.47848 2.53871 2.44385 2.40515 2.44385C2.22706 2.44385 2.05392 2.50816 1.92036 2.62688C1.61365 2.89401 1.58397 3.35901 1.8511 3.66571C2.37546 4.26922 2.95424 4.88263 3.72595 5.64444V5.64444Z M10.5571 0.0595703C10.0228 0.0595703 9.58752 0.494891 9.58752 1.02915V4.71948C9.58752 5.25373 10.0228 5.68905 10.5571 5.68905C11.0914 5.68905 11.5267 5.25373 11.5267 4.71948V2.05809C15.5237 2.54287 18.6155 6.00565 18.6155 10.062C18.6155 14.5043 14.9993 18.1204 10.5571 18.1204C6.11485 18.1204 2.49872 14.5043 2.49872 10.0571C2.49872 9.52284 2.0634 9.08752 1.52915 9.08752C0.994891 9.08752 0.55957 9.52284 0.55957 10.0571C0.55957 15.5728 5.04634 20.0546 10.5571 20.0546C16.0679 20.0546 20.5546 15.5679 20.5546 10.0571C20.5546 4.54634 16.0728 0.0595703 10.5571 0.0595703Z',
viewBox: '0 0 21 21',
ratio: 1,
},
sun: {
@ -440,12 +474,6 @@ export const icons: Record<string, { path: string; viewBox: string; ratio: numbe
viewBox: '0 0 25 21',
ratio: 1,
},
upload: {
path:
'M380.032,133.472l-112-128C264.992,2.016,260.608,0,256,0c-4.608,0-8.992,2.016-12.032,5.472l-112,128 c-4.128,4.736-5.152,11.424-2.528,17.152C132.032,156.32,137.728,160,144,160h64v208c0,8.832,7.168,16,16,16h64 c8.832,0,16-7.168,16-16V160h64c6.272,0,11.968-3.648,14.56-9.376C385.152,144.896,384.192,138.176,380.032,133.472z M432,352v96H80v-96H16v128c0,17.696,14.336,32,32,32h416c17.696,0,32-14.304,32-32V352H432z',
viewBox: '0 0 512 512',
ratio: 1,
},
warning: {
path:
'M243.225,333.382c-13.6,0-25,11.4-25,25s11.4,25,25,25c13.1,0,25-11.4,24.4-24.4 C268.225,344.682,256.925,333.382,243.225,333.382z M474.625,421.982c15.7-27.1,15.8-59.4,0.2-86.4l-156.6-271.2c-15.5-27.3-43.5-43.5-74.9-43.5s-59.4,16.3-74.9,43.4 l-156.8,271.5c-15.6,27.3-15.5,59.8,0.3,86.9c15.6,26.8,43.5,42.9,74.7,42.9h312.8 C430.725,465.582,458.825,449.282,474.625,421.982z M440.625,402.382c-8.7,15-24.1,23.9-41.3,23.9h-312.8 c-17,0-32.3-8.7-40.8-23.4c-8.6-14.9-8.7-32.7-0.1-47.7l156.8-271.4c8.5-14.9,23.7-23.7,40.9-23.7c17.1,0,32.4,8.9,40.9,23.8 l156.7,271.4C449.325,369.882,449.225,387.482,440.625,402.382z M237.025,157.882c-11.9,3.4-19.3,14.2-19.3,27.3c0.6,7.9,1.1,15.9,1.7,23.8c1.7,30.1,3.4,59.6,5.1,89.7 c0.6,10.2,8.5,17.6,18.7,17.6c10.2,0,18.2-7.9,18.7-18.2c0-6.2,0-11.9,0.6-18.2c1.1-19.3,2.3-38.6,3.4-57.9 c0.6-12.5,1.7-25,2.3-37.5c0-4.5-0.6-8.5-2.3-12.5C260.825,160.782,248.925,155.082,237.025,157.882z',

@ -102,7 +102,7 @@ const animation = (props: {
}) => {
if (props.rotateDuration) {
return css`
${rotate} ${props.rotateDuration}s linear infinite;
animation: ${rotate} ${props.rotateDuration}s linear infinite;
`;
}
if (props.noScale) {
@ -111,11 +111,8 @@ const animation = (props: {
if (props.glowDuration !== undefined && props.glowStartDelay !== undefined && props.iconColor) {
return css`
${glow(
props.iconColor,
props.glowDuration,
props.glowStartDelay
)} ${props.glowDuration}s ease infinite;
animation: ${glow(props.iconColor, props.glowDuration, props.glowStartDelay)}
${props.glowDuration}s ease infinite;
`;
}
return undefined;
@ -124,7 +121,7 @@ const animation = (props: {
const Svg = React.memo(styled.svg<StyledSvgProps>`
width: ${props => props.width};
transform: ${props => `rotate(${props.iconRotation}deg)`};
animation: ${props => animation(props)};
${props => animation(props)};
border-radius: ${props => props.borderRadius};
background-color: ${props =>
props.backgroundColor ? props.backgroundColor : '--button-icon-background-color'};

@ -1,4 +1,4 @@
import React from 'react';
import React, { KeyboardEvent } from 'react';
import classNames from 'classnames';
import _ from 'lodash';
import styled from 'styled-components';
@ -7,7 +7,7 @@ import { SessionIcon, SessionIconProps } from '.';
import { SessionNotificationCount } from './SessionNotificationCount';
interface SProps extends SessionIconProps {
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onClick?: (e?: React.MouseEvent<HTMLDivElement>) => void;
notificationCount?: number;
isSelected?: boolean;
isHidden?: boolean;
@ -16,6 +16,7 @@ interface SProps extends SessionIconProps {
dataTestIdIcon?: string;
id?: string;
style?: object;
tabIndex?: number;
}
const StyledSessionIconButton = styled.div<{ color?: string; isSelected?: boolean }>`
@ -59,6 +60,7 @@ const SessionIconButtonInner = React.forwardRef<HTMLDivElement, SProps>((props,
dataTestId,
dataTestIdIcon,
style,
tabIndex,
} = props;
const clickHandler = (e: React.MouseEvent<HTMLDivElement>) => {
if (props.onClick) {
@ -66,6 +68,12 @@ const SessionIconButtonInner = React.forwardRef<HTMLDivElement, SProps>((props,
props.onClick(e);
}
};
const keyPressHandler = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.currentTarget.tabIndex > -1 && e.key === 'Enter' && props.onClick) {
e.stopPropagation();
props.onClick();
}
};
return (
<StyledSessionIconButton
@ -77,6 +85,8 @@ const SessionIconButtonInner = React.forwardRef<HTMLDivElement, SProps>((props,
id={id}
onClick={clickHandler}
style={{ ...style, display: isHidden ? 'none' : 'flex', margin: margin || '' }}
tabIndex={tabIndex}
onKeyPress={keyPressHandler}
data-testid={dataTestId}
>
<SessionIcon

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

Loading…
Cancel
Save