Merge pull request #3016 from oxen-io/userconfig_disappearingmessage
Disappearing messages v2pull/3019/head
commit
ad9905d2ac
@ -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'});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 };
|
@ -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,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,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;
|
||||
}
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue