Emoji Reacts (#2320)
Add support for emoji reacts in conversations Resolves #2375 and #1577pull/2428/head
parent
67153bbb07
commit
267f49ff1e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 839 KiB |
File diff suppressed because one or more lines are too long
@ -1,32 +1,137 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef, MutableRefObject, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Picker } from 'emoji-mart';
|
||||
import { Constants } from '../../session';
|
||||
import styled from 'styled-components';
|
||||
import data from '@emoji-mart/data';
|
||||
// @ts-ignore
|
||||
import { Picker } from '../../../node_modules/emoji-mart/dist/index.cjs';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getTheme } from '../../state/selectors/theme';
|
||||
import { noop } from 'lodash';
|
||||
import { loadEmojiPanelI18n } from '../../util/i18n';
|
||||
import { FixedBaseEmoji, FixedPickerProps } from '../../types/Reaction';
|
||||
|
||||
export const StyledEmojiPanel = styled.div<{ isModal: boolean; theme: 'light' | 'dark' }>`
|
||||
padding: var(--margins-lg);
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: var(--default-duration);
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
em-emoji-picker {
|
||||
background-color: var(--color-cell-background);
|
||||
border: 1px solid var(--color-session-border);
|
||||
padding-bottom: var(--margins-sm);
|
||||
--shadow: none;
|
||||
--border-radius: 8px;
|
||||
--color-border: var(--color-session-border);
|
||||
--font-family: var(--font-default);
|
||||
--font-size: var(--font-size-sm);
|
||||
--rgb-accent: 0, 247, 130; // Constants.UI.COLORS.GREEN
|
||||
|
||||
${props => {
|
||||
switch (props.theme) {
|
||||
case 'dark':
|
||||
return `
|
||||
--background-rgb: 27, 27, 27; // var(--color-cell-background)
|
||||
--rgb-background: 27, 27, 27;
|
||||
--rgb-color: 255, 255, 255; // var(--color-text)
|
||||
--rgb-input: 27, 27, 27;
|
||||
`;
|
||||
case 'light':
|
||||
default:
|
||||
return `
|
||||
--background-rgb: 249, 249, 249; // var(--color-cell-background)
|
||||
--rgb-background: 249, 249, 249;
|
||||
--rgb-color: 0, 0, 0; // var(--color-text)
|
||||
--rgb-input: 249, 249, 249;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
${props =>
|
||||
!props.isModal &&
|
||||
`
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 40px);
|
||||
left: calc(100% - 79px);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-color: var(--color-cell-background);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 3px;
|
||||
transform: scaleY(1.4) rotate(45deg);
|
||||
border: 0.7px solid var(--color-session-border);
|
||||
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
|
||||
}
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
onEmojiClicked: (emoji: any) => void;
|
||||
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
|
||||
show: boolean;
|
||||
isModal?: boolean;
|
||||
onKeyDown?: (event: any) => void;
|
||||
};
|
||||
|
||||
export const SessionEmojiPanel = (props: Props) => {
|
||||
const { onEmojiClicked, show } = props;
|
||||
const darkMode = useSelector(getTheme) === 'dark';
|
||||
const pickerProps: FixedPickerProps = {
|
||||
title: '',
|
||||
showPreview: true,
|
||||
autoFocus: true,
|
||||
skinTonePosition: 'preview',
|
||||
};
|
||||
|
||||
export const SessionEmojiPanel = forwardRef<HTMLDivElement, Props>((props: Props, ref) => {
|
||||
const { onEmojiClicked, show, isModal = false, onKeyDown } = props;
|
||||
const theme = useSelector(getTheme);
|
||||
const pickerRef = ref as MutableRefObject<HTMLDivElement>;
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
if (pickerRef.current !== null) {
|
||||
if (pickerRef.current.children.length === 0) {
|
||||
loadEmojiPanelI18n()
|
||||
.then(async i18n => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
// tslint:disable-next-line: no-unused-expression
|
||||
new Picker({
|
||||
data,
|
||||
ref,
|
||||
i18n,
|
||||
theme,
|
||||
onEmojiSelect: onEmojiClicked,
|
||||
onKeyDown,
|
||||
...pickerProps,
|
||||
});
|
||||
})
|
||||
.catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [data, pickerProps]);
|
||||
|
||||
return (
|
||||
<div className={classNames('session-emoji-panel', show && 'show')}>
|
||||
<Picker
|
||||
backgroundImageFn={() => './images/emoji/emoji-sheet-twitter-32.png'}
|
||||
set={'twitter'}
|
||||
sheetSize={32}
|
||||
darkMode={darkMode}
|
||||
color={Constants.UI.COLORS.GREEN}
|
||||
showPreview={true}
|
||||
title={''}
|
||||
onSelect={onEmojiClicked}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<StyledEmojiPanel
|
||||
isModal={isModal}
|
||||
theme={theme}
|
||||
className={classNames(show && 'show')}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -0,0 +1,92 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { getRecentReactions } from '../../../../util/storage';
|
||||
import { SessionIconButton } from '../../../icon';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import { isEqual } from 'lodash';
|
||||
import { RecentReactions } from '../../../../types/Reaction';
|
||||
|
||||
type Props = {
|
||||
action: (...args: Array<any>) => void;
|
||||
additionalAction: (...args: Array<any>) => void;
|
||||
};
|
||||
|
||||
const StyledMessageReactBar = styled.div`
|
||||
background-color: var(--color-received-message-background);
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 2px 16px 0 rgba(0, 0, 0, 0.2), 0 0px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
|
||||
position: absolute;
|
||||
top: -56px;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
width: 302px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.session-icon-button {
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
margin: 0 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ReactButton = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 300px;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
|
||||
:hover {
|
||||
background-color: var(--color-compose-view-button-background);
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageReactBar = (props: Props): ReactElement => {
|
||||
const { action, additionalAction } = props;
|
||||
const [recentReactions, setRecentReactions] = useState<RecentReactions>();
|
||||
|
||||
useEffect(() => {
|
||||
const reactions = new RecentReactions(getRecentReactions());
|
||||
if (reactions && !isEqual(reactions, recentReactions)) {
|
||||
setRecentReactions(reactions);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!recentReactions) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledMessageReactBar>
|
||||
{recentReactions &&
|
||||
recentReactions.items.map(emoji => (
|
||||
<ReactButton
|
||||
key={emoji}
|
||||
role={'img'}
|
||||
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
|
||||
onClick={() => {
|
||||
action(emoji);
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</ReactButton>
|
||||
))}
|
||||
<SessionIconButton
|
||||
iconColor={'var(--color-text)'}
|
||||
iconPadding={'12px'}
|
||||
iconSize={'huge2'}
|
||||
iconType="plusThin"
|
||||
backgroundColor={'var(--color-compose-view-button-background)'}
|
||||
borderRadius="300px"
|
||||
onClick={additionalAction}
|
||||
/>
|
||||
</StyledMessageReactBar>
|
||||
);
|
||||
};
|
@ -0,0 +1,218 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { MessageRenderingProps } from '../../../../models/messageType';
|
||||
import { isEmpty, isEqual } from 'lodash';
|
||||
import { SortedReactionList } from '../../../../types/Reaction';
|
||||
import { StyledPopupContainer } from '../reactions/ReactionPopup';
|
||||
import { Flex } from '../../../basic/Flex';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import { Reaction, ReactionProps } from '../reactions/Reaction';
|
||||
import { SessionIcon } from '../../../icon';
|
||||
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
|
||||
|
||||
export const popupXDefault = -101;
|
||||
export const popupYDefault = -90;
|
||||
|
||||
const StyledMessageReactionsContainer = styled(Flex)<{ x: number; y: number }>`
|
||||
${StyledPopupContainer} {
|
||||
position: absolute;
|
||||
top: ${props => `${props.y}px;`};
|
||||
left: ${props => `${props.x}px;`};
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledMessageReactions = styled(Flex)<{ inModal: boolean }>`
|
||||
${props => (props.inModal ? '' : 'max-width: 320px;')}
|
||||
`;
|
||||
|
||||
const StyledReactionOverflow = styled.button`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
border: none;
|
||||
margin-right: 4px;
|
||||
margin-bottom: var(--margins-sm);
|
||||
|
||||
span {
|
||||
background-color: var(--color-received-message-background);
|
||||
border: 1px solid var(--color-inbox-background);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-right: -9px;
|
||||
padding: 1px 4.5px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReadLess = styled.span`
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
margin-right: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
type ReactionsProps = Omit<ReactionProps, 'emoji'>;
|
||||
|
||||
const Reactions = (props: ReactionsProps): ReactElement => {
|
||||
const { messageId, reactions, inModal } = props;
|
||||
return (
|
||||
<StyledMessageReactions
|
||||
container={true}
|
||||
flexWrap={inModal ? 'nowrap' : 'wrap'}
|
||||
alignItems={'center'}
|
||||
inModal={inModal}
|
||||
>
|
||||
{reactions.map(([emoji, _]) => (
|
||||
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
|
||||
))}
|
||||
</StyledMessageReactions>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExpandReactionsProps extends ReactionsProps {
|
||||
handleExpand: () => void;
|
||||
}
|
||||
|
||||
const CompressedReactions = (props: ExpandReactionsProps): ReactElement => {
|
||||
const { messageId, reactions, inModal, handleExpand } = props;
|
||||
return (
|
||||
<StyledMessageReactions
|
||||
container={true}
|
||||
flexWrap={inModal ? 'nowrap' : 'wrap'}
|
||||
alignItems={'center'}
|
||||
inModal={inModal}
|
||||
>
|
||||
{reactions.slice(0, 4).map(([emoji, _]) => (
|
||||
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
|
||||
))}
|
||||
<StyledReactionOverflow onClick={handleExpand}>
|
||||
{reactions
|
||||
.slice(4, 7)
|
||||
.reverse()
|
||||
.map(([emoji, _]) => {
|
||||
return (
|
||||
<span
|
||||
key={`${messageId}-${emoji}`}
|
||||
role={'img'}
|
||||
aria-label={
|
||||
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined
|
||||
}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</StyledReactionOverflow>
|
||||
</StyledMessageReactions>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpandedReactions = (props: ExpandReactionsProps): ReactElement => {
|
||||
const { handleExpand } = props;
|
||||
return (
|
||||
<>
|
||||
<Reactions {...props} />
|
||||
<StyledReadLess onClick={handleExpand}>
|
||||
<SessionIcon iconType="chevron" iconSize="medium" iconRotation={180} />
|
||||
{window.i18n('expandedReactionsText')}
|
||||
</StyledReadLess>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageReactsSelectorProps = Pick<
|
||||
MessageRenderingProps,
|
||||
'convoId' | 'conversationType' | 'isPublic' | 'serverId' | 'reacts' | 'sortedReacts'
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
messageId: string;
|
||||
hasReactLimit?: boolean;
|
||||
onClick: (emoji: string) => void;
|
||||
popupReaction?: string;
|
||||
setPopupReaction?: (emoji: string) => void;
|
||||
onPopupClick?: () => void;
|
||||
inModal?: boolean;
|
||||
onSelected?: (emoji: string) => boolean;
|
||||
};
|
||||
|
||||
export const MessageReactions = (props: Props): ReactElement => {
|
||||
const {
|
||||
messageId,
|
||||
hasReactLimit = true,
|
||||
onClick,
|
||||
popupReaction,
|
||||
setPopupReaction,
|
||||
onPopupClick,
|
||||
inModal = false,
|
||||
onSelected,
|
||||
} = props;
|
||||
const [reactions, setReactions] = useState<SortedReactionList>([]);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const handleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const [popupX, setPopupX] = useState(popupXDefault);
|
||||
const [popupY, setPopupY] = useState(popupYDefault);
|
||||
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
|
||||
useEffect(() => {
|
||||
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
|
||||
setReactions(msgProps?.sortedReacts);
|
||||
}
|
||||
|
||||
if (!isEmpty(reactions) && isEmpty(msgProps?.sortedReacts)) {
|
||||
setReactions([]);
|
||||
}
|
||||
}, [msgProps?.sortedReacts, reactions]);
|
||||
|
||||
if (!msgProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { conversationType, sortedReacts } = msgProps;
|
||||
const inGroup = conversationType === 'group';
|
||||
|
||||
const reactLimit = 6;
|
||||
|
||||
const reactionsProps = {
|
||||
messageId,
|
||||
reactions,
|
||||
inModal,
|
||||
inGroup,
|
||||
handlePopupX: setPopupX,
|
||||
handlePopupY: setPopupY,
|
||||
onClick,
|
||||
popupReaction,
|
||||
onSelected,
|
||||
handlePopupReaction: setPopupReaction,
|
||||
handlePopupClick: onPopupClick,
|
||||
};
|
||||
|
||||
const ExtendedReactions = isExpanded ? ExpandedReactions : CompressedReactions;
|
||||
|
||||
return (
|
||||
<StyledMessageReactionsContainer
|
||||
container={true}
|
||||
flexDirection={'column'}
|
||||
justifyContent={'center'}
|
||||
alignItems={inModal ? 'flex-start' : 'center'}
|
||||
x={popupX}
|
||||
y={popupY}
|
||||
>
|
||||
{sortedReacts &&
|
||||
sortedReacts !== [] &&
|
||||
(!hasReactLimit || sortedReacts.length <= reactLimit ? (
|
||||
<Reactions {...reactionsProps} />
|
||||
) : (
|
||||
<ExtendedReactions handleExpand={handleExpand} {...reactionsProps} />
|
||||
))}
|
||||
</StyledMessageReactionsContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,158 @@
|
||||
import React, { ReactElement, useRef, useState } from 'react';
|
||||
import { SortedReactionList } from '../../../../types/Reaction';
|
||||
import { UserUtils } from '../../../../session/utils';
|
||||
import { abbreviateNumber } from '../../../../util/abbreviateNumber';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import styled from 'styled-components';
|
||||
import { useMouse } from 'react-use';
|
||||
import { ReactionPopup, TipPosition } from './ReactionPopup';
|
||||
import { popupXDefault, popupYDefault } from '../message-content/MessageReactions';
|
||||
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
|
||||
const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; showCount: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${props => (props.showCount ? 'flex-start' : 'center')};
|
||||
align-items: center;
|
||||
|
||||
background-color: var(--color-received-message-background);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${props => (props.selected ? 'var(--color-accent)' : 'transparent')};
|
||||
border-radius: 11px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 7px;
|
||||
margin: 0 4px var(--margins-sm);
|
||||
height: 24px;
|
||||
min-width: ${props => (props.showCount ? '48px' : '24px')};
|
||||
${props => props.inModal && 'width: 100%;'}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionContainer = styled.div<{
|
||||
inModal: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
${props => props.inModal && 'margin-right: 8px;'}
|
||||
`;
|
||||
|
||||
export type ReactionProps = {
|
||||
emoji: string;
|
||||
messageId: string;
|
||||
reactions: SortedReactionList;
|
||||
inModal: boolean;
|
||||
inGroup: boolean;
|
||||
handlePopupX: (x: number) => void;
|
||||
handlePopupY: (y: number) => void;
|
||||
onClick: (emoji: string) => void;
|
||||
popupReaction?: string;
|
||||
onSelected?: (emoji: string) => boolean;
|
||||
handlePopupReaction?: (emoji: string) => void;
|
||||
handlePopupClick?: () => void;
|
||||
};
|
||||
|
||||
export const Reaction = (props: ReactionProps): ReactElement => {
|
||||
const {
|
||||
emoji,
|
||||
messageId,
|
||||
reactions,
|
||||
inModal,
|
||||
inGroup,
|
||||
handlePopupX,
|
||||
handlePopupY,
|
||||
onClick,
|
||||
popupReaction,
|
||||
onSelected,
|
||||
handlePopupReaction,
|
||||
handlePopupClick,
|
||||
} = props;
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const senders = reactionsMap[emoji].senders ? Object.keys(reactionsMap[emoji].senders) : [];
|
||||
const count = reactionsMap[emoji].count;
|
||||
const showCount = count !== undefined && (count > 1 || inGroup);
|
||||
|
||||
const reactionRef = useRef<HTMLDivElement>(null);
|
||||
const { docX, elW } = useMouse(reactionRef);
|
||||
|
||||
const gutterWidth = 380;
|
||||
const tooltipMidPoint = 108; // width is 216px;
|
||||
const [tooltipPosition, setTooltipPosition] = useState<TipPosition>('center');
|
||||
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
const isBlindedMe =
|
||||
senders && senders.length > 0 && senders.filter(isUsAnySogsFromCache).length > 0;
|
||||
|
||||
const selected = () => {
|
||||
if (onSelected) {
|
||||
return onSelected(emoji);
|
||||
}
|
||||
|
||||
return senders && senders.length > 0 && (senders.includes(me) || isBlindedMe);
|
||||
};
|
||||
|
||||
const handleReactionClick = () => {
|
||||
onClick(emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledReactionContainer ref={reactionRef} inModal={inModal}>
|
||||
<StyledReaction
|
||||
showCount={showCount}
|
||||
selected={selected()}
|
||||
inModal={inModal}
|
||||
onClick={handleReactionClick}
|
||||
onMouseEnter={() => {
|
||||
if (inGroup) {
|
||||
const { innerWidth: windowWidth } = window;
|
||||
if (handlePopupReaction) {
|
||||
// overflow on far right means we shift left
|
||||
if (docX + tooltipMidPoint > windowWidth) {
|
||||
handlePopupX(Math.abs(popupXDefault) * 1.5 * -1);
|
||||
setTooltipPosition('right');
|
||||
// overflow onto conversations means we lock to the right
|
||||
} else if (docX - elW <= gutterWidth + tooltipMidPoint) {
|
||||
const offset = -12.5;
|
||||
handlePopupX(offset);
|
||||
setTooltipPosition('left');
|
||||
} else {
|
||||
handlePopupX(popupXDefault);
|
||||
setTooltipPosition('center');
|
||||
}
|
||||
|
||||
handlePopupReaction(emoji);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
role={'img'}
|
||||
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
|
||||
>
|
||||
{emoji}
|
||||
{showCount && `\u00A0\u00A0${abbreviateNumber(count)}`}
|
||||
</span>
|
||||
</StyledReaction>
|
||||
{inGroup && popupReaction && popupReaction === emoji && (
|
||||
<ReactionPopup
|
||||
messageId={messageId}
|
||||
emoji={popupReaction}
|
||||
senders={Object.keys(reactionsMap[popupReaction].senders)}
|
||||
tooltipPosition={tooltipPosition}
|
||||
onClick={() => {
|
||||
if (handlePopupReaction) {
|
||||
handlePopupReaction('');
|
||||
}
|
||||
handlePopupX(popupXDefault);
|
||||
handlePopupY(popupYDefault);
|
||||
setTooltipPosition('center');
|
||||
if (handlePopupClick) {
|
||||
handlePopupClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledReactionContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,146 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { PubKey } from '../../../../session/types/PubKey';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import { readableList } from '../../../../util/readableList';
|
||||
|
||||
export type TipPosition = 'center' | 'left' | 'right';
|
||||
|
||||
export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 216px;
|
||||
height: 72px;
|
||||
z-index: 5;
|
||||
|
||||
background-color: var(--color-received-message-background);
|
||||
color: var(--color-pill-divider-text);
|
||||
box-shadow: 0px 0px 13px rgba(0, 0, 0, 0.51);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: break-word;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 19px);
|
||||
left: ${props => {
|
||||
switch (props.tooltipPosition) {
|
||||
case 'left':
|
||||
return '24px';
|
||||
case 'right':
|
||||
return 'calc(100% - 48px)';
|
||||
case 'center':
|
||||
default:
|
||||
return 'calc(100% - 100px)';
|
||||
}
|
||||
}};
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-color: var(--color-received-message-background);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 3px;
|
||||
transform: scaleY(1.4) rotate(45deg);
|
||||
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEmoji = styled.span`
|
||||
font-size: 36px;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const StyledOthers = styled.span`
|
||||
color: var(--color-accent);
|
||||
`;
|
||||
|
||||
const generateContacts = async (messageId: string, senders: Array<string>) => {
|
||||
let results = null;
|
||||
const message = await Data.getMessageById(messageId);
|
||||
if (message) {
|
||||
let meIndex = -1;
|
||||
results = senders.map((sender, index) => {
|
||||
const contact = message.findAndFormatContact(sender);
|
||||
if (contact.isMe) {
|
||||
meIndex = index;
|
||||
}
|
||||
return contact?.profileName || contact?.name || PubKey.shorten(sender);
|
||||
});
|
||||
if (meIndex >= 0) {
|
||||
results.splice(meIndex, 1);
|
||||
results = [window.i18n('you'), ...results];
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const Contacts = (contacts: string) => {
|
||||
if (!contacts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.includes('&') && contacts.includes('other')) {
|
||||
const [names, others] = contacts.split('&');
|
||||
return (
|
||||
<span>
|
||||
{names} & <StyledOthers>{others}</StyledOthers> {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{contacts} {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
senders: Array<string>;
|
||||
tooltipPosition?: TipPosition;
|
||||
onClick: (...args: Array<any>) => void;
|
||||
};
|
||||
|
||||
export const ReactionPopup = (props: Props): ReactElement => {
|
||||
const { messageId, emoji, senders, tooltipPosition = 'center', onClick } = props;
|
||||
|
||||
const [contacts, setContacts] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
generateContacts(messageId, senders)
|
||||
.then(async results => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (results && results.length > 0) {
|
||||
setContacts(readableList(results));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [generateContacts]);
|
||||
|
||||
return (
|
||||
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
|
||||
{Contacts(contacts)}
|
||||
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
|
||||
{emoji}
|
||||
</StyledEmoji>
|
||||
</StyledPopupContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,119 @@
|
||||
import React, { ReactElement, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
|
||||
import { clearSogsReactionByServerId } from '../../session/apis/open_group_api/sogsv3/sogsV3ClearReaction';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { updateReactClearAllModal } from '../../state/ducks/modalDialog';
|
||||
import { getTheme } from '../../state/selectors/theme';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
|
||||
import { SessionSpinner } from '../basic/SessionSpinner';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
|
||||
type Props = {
|
||||
reaction: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
div:first-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
div:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactClearAllContainer = styled(Flex)<{ darkMode: boolean }>`
|
||||
margin: var(--margins-lg);
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: var(--margins-lg);
|
||||
margin: var(--margins-md) auto;
|
||||
border-bottom: 1.5px solid ${props => (props.darkMode ? '#2D2D2D' : '#EEEEEE')};
|
||||
|
||||
span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-button {
|
||||
font-size: 16px;
|
||||
height: 36px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
`;
|
||||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
export const ReactClearAllModal = (props: Props): ReactElement => {
|
||||
const { reaction, messageId } = props;
|
||||
|
||||
const [clearingInProgress, setClearingInProgress] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const darkMode = useSelector(getTheme) === 'dark';
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
|
||||
if (!msgProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { convoId, serverId } = msgProps;
|
||||
const roomInfos = getConversationController()
|
||||
.get(convoId)
|
||||
.toOpenGroupV2();
|
||||
|
||||
const confirmButtonColor = darkMode ? SessionButtonColor.Green : SessionButtonColor.Secondary;
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(updateReactClearAllModal(null));
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (roomInfos && serverId) {
|
||||
setClearingInProgress(true);
|
||||
await clearSogsReactionByServerId(reaction, serverId, roomInfos);
|
||||
setClearingInProgress(false);
|
||||
handleClose();
|
||||
} else {
|
||||
window.log.warn('Error for batch removal of', reaction, 'on message', messageId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionWrapperModal
|
||||
additionalClassName={'reaction-list-modal'}
|
||||
showHeader={false}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<StyledReactClearAllContainer
|
||||
container={true}
|
||||
flexDirection={'column'}
|
||||
alignItems="center"
|
||||
darkMode={darkMode}
|
||||
>
|
||||
<p>{window.i18n('clearAllReactions', [reaction])}</p>
|
||||
<StyledButtonContainer className="session-modal__button-group">
|
||||
<SessionButton
|
||||
text={window.i18n('clear')}
|
||||
buttonColor={confirmButtonColor}
|
||||
buttonType={SessionButtonType.BrandOutline}
|
||||
onClick={handleClearAll}
|
||||
disabled={clearingInProgress}
|
||||
/>
|
||||
<SessionButton
|
||||
text={window.i18n('cancel')}
|
||||
buttonColor={SessionButtonColor.Danger}
|
||||
buttonType={SessionButtonType.BrandOutline}
|
||||
onClick={handleClose}
|
||||
disabled={clearingInProgress}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
<SessionSpinner loading={clearingInProgress} />
|
||||
</StyledReactClearAllContainer>
|
||||
</SessionWrapperModal>
|
||||
);
|
||||
};
|
@ -0,0 +1,324 @@
|
||||
import { isEmpty, isEqual } from 'lodash';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../data/data';
|
||||
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
|
||||
import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { UserUtils } from '../../session/utils';
|
||||
import {
|
||||
updateReactClearAllModal,
|
||||
updateReactListModal,
|
||||
updateUserDetailsModal,
|
||||
} from '../../state/ducks/modalDialog';
|
||||
import { SortedReactionList } from '../../types/Reaction';
|
||||
import { nativeEmojiData } from '../../util/emoji';
|
||||
import { sendMessageReaction } from '../../util/reactions';
|
||||
import { Avatar, AvatarSize } from '../avatar/Avatar';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
|
||||
import { SessionIconButton } from '../icon';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
|
||||
const StyledReactListContainer = styled(Flex)`
|
||||
width: 376px;
|
||||
`;
|
||||
|
||||
const StyledReactionsContainer = styled.div`
|
||||
background-color: var(--color-cell-background);
|
||||
border-bottom: 1px solid var(--color-session-border);
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 12px 8px 0;
|
||||
`;
|
||||
|
||||
const StyledSendersContainer = styled(Flex)`
|
||||
width: 100%;
|
||||
min-height: 350px;
|
||||
height: 100%;
|
||||
max-height: 496px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 32px;
|
||||
`;
|
||||
|
||||
const StyledReactionBar = styled(Flex)`
|
||||
width: 100%;
|
||||
margin: 12px 0 20px 4px;
|
||||
|
||||
p {
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0;
|
||||
|
||||
span:nth-child(1) {
|
||||
margin: 0 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionSender = styled(Flex)`
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
.module-avatar {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.module-conversation__user__profile-name {
|
||||
color: var(--color-text);
|
||||
font-weight: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledClearButton = styled.button`
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-destructive);
|
||||
border: none;
|
||||
`;
|
||||
|
||||
type ReactionSendersProps = {
|
||||
messageId: string;
|
||||
currentReact: string;
|
||||
senders: Array<string>;
|
||||
me: string;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
const ReactionSenders = (props: ReactionSendersProps) => {
|
||||
const { messageId, currentReact, senders, me, handleClose } = props;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleAvatarClick = async (sender: string) => {
|
||||
const message = await Data.getMessageById(messageId);
|
||||
if (message) {
|
||||
handleClose();
|
||||
const contact = message.findAndFormatContact(sender);
|
||||
dispatch(
|
||||
updateUserDetailsModal({
|
||||
conversationId: sender,
|
||||
userName: contact.name || contact.profileName || sender,
|
||||
authorAvatarPath: contact.avatarPath,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveReaction = async () => {
|
||||
await sendMessageReaction(messageId, currentReact);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{senders.map((sender: string) => (
|
||||
<StyledReactionSender
|
||||
key={`${messageId}-${sender}`}
|
||||
container={true}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
>
|
||||
<Flex container={true} alignItems={'center'}>
|
||||
<Avatar
|
||||
size={AvatarSize.XS}
|
||||
pubkey={sender}
|
||||
onAvatarClick={async () => {
|
||||
await handleAvatarClick(sender);
|
||||
}}
|
||||
/>
|
||||
{sender === me ? (
|
||||
window.i18n('you')
|
||||
) : (
|
||||
<ContactName
|
||||
pubkey={sender}
|
||||
module="module-conversation__user"
|
||||
shouldShowPubkey={false}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{sender === me && (
|
||||
<SessionIconButton
|
||||
iconType="exit"
|
||||
iconSize="small"
|
||||
onClick={async () => {
|
||||
await handleRemoveReaction();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledReactionSender>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reaction: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const handleSenders = (senders: Array<string>, me: string) => {
|
||||
let updatedSenders = senders;
|
||||
const blindedMe = updatedSenders.filter(isUsAnySogsFromCache);
|
||||
|
||||
let meIndex = -1;
|
||||
if (blindedMe && blindedMe[0]) {
|
||||
meIndex = updatedSenders.indexOf(blindedMe[0]);
|
||||
} else {
|
||||
meIndex = updatedSenders.indexOf(me);
|
||||
}
|
||||
if (meIndex >= 0) {
|
||||
updatedSenders.splice(meIndex, 1);
|
||||
updatedSenders = [me, ...updatedSenders];
|
||||
}
|
||||
|
||||
return updatedSenders;
|
||||
};
|
||||
|
||||
export const ReactListModal = (props: Props): ReactElement => {
|
||||
const { reaction, messageId } = props;
|
||||
|
||||
const [reactions, setReactions] = useState<SortedReactionList>([]);
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const [currentReact, setCurrentReact] = useState('');
|
||||
const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>();
|
||||
const [senders, setSenders] = useState<Array<string>>([]);
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
|
||||
// tslint:disable: cyclomatic-complexity
|
||||
useEffect(() => {
|
||||
if (currentReact === '' && currentReact !== reaction) {
|
||||
setReactAriaLabel(
|
||||
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[reaction] : undefined
|
||||
);
|
||||
setCurrentReact(reaction);
|
||||
}
|
||||
|
||||
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
|
||||
setReactions(msgProps?.sortedReacts);
|
||||
}
|
||||
|
||||
if (
|
||||
reactions &&
|
||||
reactions.length > 0 &&
|
||||
(msgProps?.sortedReacts === [] || msgProps?.sortedReacts === undefined)
|
||||
) {
|
||||
setReactions([]);
|
||||
}
|
||||
|
||||
let _senders =
|
||||
reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders
|
||||
? Object.keys(reactionsMap[currentReact].senders)
|
||||
: null;
|
||||
|
||||
if (_senders && !isEqual(senders, _senders)) {
|
||||
if (_senders.length > 0) {
|
||||
_senders = handleSenders(_senders, me);
|
||||
}
|
||||
setSenders(_senders);
|
||||
}
|
||||
|
||||
if (senders.length > 0 && (!reactionsMap[currentReact]?.senders || isEmpty(_senders))) {
|
||||
setSenders([]);
|
||||
}
|
||||
}, [currentReact, me, reaction, msgProps?.sortedReacts, reactionsMap, senders]);
|
||||
|
||||
if (!msgProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { convoId, isPublic } = msgProps;
|
||||
|
||||
const convo = getConversationController().get(convoId);
|
||||
const weAreModerator = convo.getConversationModelProps().weAreModerator;
|
||||
|
||||
const handleSelectedReaction = (emoji: string): boolean => {
|
||||
return currentReact === emoji;
|
||||
};
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactAriaLabel(nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined);
|
||||
setCurrentReact(emoji);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(updateReactListModal(null));
|
||||
};
|
||||
|
||||
const handleClearReactions = (event: any) => {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
dispatch(
|
||||
updateReactClearAllModal({
|
||||
reaction: currentReact,
|
||||
messageId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionWrapperModal
|
||||
additionalClassName={'reaction-list-modal'}
|
||||
showHeader={false}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<StyledReactListContainer container={true} flexDirection={'column'} alignItems={'flex-start'}>
|
||||
<StyledReactionsContainer>
|
||||
<MessageReactions
|
||||
messageId={messageId}
|
||||
hasReactLimit={false}
|
||||
inModal={true}
|
||||
onSelected={handleSelectedReaction}
|
||||
onClick={handleReactionClick}
|
||||
/>
|
||||
</StyledReactionsContainer>
|
||||
{reactionsMap && currentReact && (
|
||||
<StyledSendersContainer
|
||||
container={true}
|
||||
flexDirection={'column'}
|
||||
alignItems={'flex-start'}
|
||||
>
|
||||
<StyledReactionBar
|
||||
container={true}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
>
|
||||
<p>
|
||||
<span role={'img'} aria-label={reactAriaLabel}>
|
||||
{currentReact}
|
||||
</span>
|
||||
{reactionsMap[currentReact].count && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{reactionsMap[currentReact].count}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isPublic && weAreModerator && (
|
||||
<StyledClearButton onClick={handleClearReactions}>
|
||||
{window.i18n('clearAll')}
|
||||
</StyledClearButton>
|
||||
)}
|
||||
</StyledReactionBar>
|
||||
{senders && senders.length > 0 && (
|
||||
<ReactionSenders
|
||||
messageId={messageId}
|
||||
currentReact={currentReact}
|
||||
senders={senders}
|
||||
me={me}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</StyledSendersContainer>
|
||||
)}
|
||||
</StyledReactListContainer>
|
||||
</SessionWrapperModal>
|
||||
);
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
import AbortController from 'abort-controller';
|
||||
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
|
||||
import {
|
||||
batchFirstSubIsSuccess,
|
||||
batchGlobalIsSuccess,
|
||||
OpenGroupBatchRow,
|
||||
sogsBatchSend,
|
||||
} from './sogsV3BatchPoll';
|
||||
import { hasReactionSupport } from './sogsV3SendReaction';
|
||||
|
||||
/**
|
||||
* Clears a reaction on open group server using onion v4 logic and batch send
|
||||
* User must have moderator permissions
|
||||
* Clearing implies removing all reactors for a specific emoji
|
||||
*/
|
||||
export const clearSogsReactionByServerId = async (
|
||||
reaction: string,
|
||||
serverId: number,
|
||||
roomInfos: OpenGroupRequestCommonType
|
||||
): Promise<boolean> => {
|
||||
const canReact = await hasReactionSupport(serverId);
|
||||
if (!canReact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const options: Array<OpenGroupBatchRow> = [
|
||||
{
|
||||
type: 'deleteReaction',
|
||||
deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId },
|
||||
},
|
||||
];
|
||||
const result = await sogsBatchSend(
|
||||
roomInfos.serverUrl,
|
||||
new Set([roomInfos.roomId]),
|
||||
new AbortController().signal,
|
||||
options,
|
||||
'batch'
|
||||
);
|
||||
|
||||
try {
|
||||
return batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result);
|
||||
} catch (e) {
|
||||
window?.log?.error("clearSogsReactionByServerId Can't decode JSON body");
|
||||
}
|
||||
return false;
|
||||
};
|
@ -0,0 +1,91 @@
|
||||
import { AbortSignal } from 'abort-controller';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction';
|
||||
import { getEmojiDataFromNative } from '../../../../util/emoji';
|
||||
import { OnionSending } from '../../../onions/onionSend';
|
||||
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
|
||||
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
|
||||
|
||||
export const hasReactionSupport = async (serverId: number): Promise<boolean> => {
|
||||
const found = await Data.getMessageByServerId(serverId);
|
||||
if (!found) {
|
||||
window.log.warn(`Open Group Message ${serverId} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const conversationModel = found?.getConversation();
|
||||
if (!conversationModel) {
|
||||
window.log.warn(`Conversation for ${serverId} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!conversationModel.hasReactions()) {
|
||||
window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendSogsReactionOnionV4 = async (
|
||||
serverUrl: string,
|
||||
room: string,
|
||||
abortSignal: AbortSignal,
|
||||
reaction: Reaction,
|
||||
blinded: boolean
|
||||
): Promise<boolean> => {
|
||||
const allValidRoomInfos = OpenGroupPollingUtils.getAllValidRoomInfos(serverUrl, new Set([room]));
|
||||
if (!allValidRoomInfos?.length) {
|
||||
window?.log?.info('getSendReactionRequest: no valid roominfos got.');
|
||||
throw new Error(`Could not find sogs pubkey of url:${serverUrl}`);
|
||||
}
|
||||
|
||||
const canReact = await hasReactionSupport(reaction.id);
|
||||
if (!canReact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// for an invalid reaction we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji
|
||||
const emoji = getEmojiDataFromNative(reaction.emoji) ? reaction.emoji : '🖾';
|
||||
const endpoint = `/room/${room}/reaction/${reaction.id}/${emoji}`;
|
||||
const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE';
|
||||
const serverPubkey = allValidRoomInfos[0].serverPublicKey;
|
||||
|
||||
// reaction endpoint requires an empty dict {}
|
||||
const stringifiedBody = null;
|
||||
const result = await OnionSending.sendJsonViaOnionV4ToSogs({
|
||||
serverUrl,
|
||||
endpoint,
|
||||
serverPubkey,
|
||||
method,
|
||||
abortSignal,
|
||||
blinded,
|
||||
stringifiedBody,
|
||||
headers: null,
|
||||
throwErrors: true,
|
||||
});
|
||||
|
||||
if (!batchGlobalIsSuccess(result)) {
|
||||
window?.log?.warn('sendSogsReactionWithOnionV4 Got unknown status code; res:', result);
|
||||
throw new Error(
|
||||
`sendSogsReactionOnionV4: invalid status code: ${parseBatchGlobalStatusCode(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Could not putReaction, res is invalid');
|
||||
}
|
||||
|
||||
const rawMessage = result.body as OpenGroupReactionResponse;
|
||||
if (!rawMessage) {
|
||||
throw new Error('putReaction parsing failed');
|
||||
}
|
||||
|
||||
window.log.info(
|
||||
`You ${reaction.action === Action.REACT ? 'added' : 'removed'} a`,
|
||||
reaction.emoji,
|
||||
`reaction on ${serverUrl}/${room}`
|
||||
);
|
||||
const success = Boolean(reaction.action === Action.REACT ? rawMessage.added : rawMessage.removed);
|
||||
return success;
|
||||
};
|
Binary file not shown.
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,132 @@
|
||||
import chai, { expect } from 'chai';
|
||||
import Sinon, { useFakeTimers } from 'sinon';
|
||||
import { handleMessageReaction, sendMessageReaction } from '../../../../util/reactions';
|
||||
import { Data } from '../../../../data/data';
|
||||
import * as Storage from '../../../../util/storage';
|
||||
import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils';
|
||||
import { DEFAULT_RECENT_REACTS } from '../../../../session/constants';
|
||||
import { noop } from 'lodash';
|
||||
import { UserUtils } from '../../../../session/utils';
|
||||
import { SignalService } from '../../../../protobuf';
|
||||
import { MessageCollection } from '../../../../models/message';
|
||||
import chaiAsPromised from 'chai-as-promised';
|
||||
|
||||
chai.use(chaiAsPromised as any);
|
||||
|
||||
describe('ReactionMessage', () => {
|
||||
stubWindowLog();
|
||||
|
||||
let clock: Sinon.SinonFakeTimers;
|
||||
const ourNumber = '0123456789abcdef';
|
||||
const originalMessage = generateFakeIncomingPrivateMessage();
|
||||
originalMessage.set('sent_at', Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
Sinon.stub(originalMessage, 'getConversation').returns({
|
||||
hasReactions: () => true,
|
||||
sendReaction: noop,
|
||||
} as any);
|
||||
|
||||
// sendMessageReaction stubs
|
||||
Sinon.stub(Data, 'getMessageById').resolves(originalMessage);
|
||||
Sinon.stub(Storage, 'getRecentReactions').returns(DEFAULT_RECENT_REACTS);
|
||||
Sinon.stub(Storage, 'saveRecentReations').resolves();
|
||||
Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(ourNumber);
|
||||
|
||||
// handleMessageReaction stubs
|
||||
Sinon.stub(Data, 'getMessagesBySentAt').resolves(new MessageCollection([originalMessage]));
|
||||
Sinon.stub(originalMessage, 'commit').resolves();
|
||||
});
|
||||
|
||||
it('can react to a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
|
||||
expect(reaction?.action, 'action should be 0').to.be.equal(0);
|
||||
|
||||
// Handling reaction
|
||||
const updatedMessage = await handleMessageReaction(
|
||||
reaction as SignalService.DataMessage.IReaction,
|
||||
ourNumber,
|
||||
false,
|
||||
originalMessage.get('id')
|
||||
);
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
|
||||
.undefined;
|
||||
// tslint:disable: no-non-null-assertion
|
||||
expect(updatedMessage?.get('reacts')!['😄'], 'reacts should have 😄 key').to.not.be.undefined;
|
||||
// tslint:disable: no-non-null-assertion
|
||||
expect(
|
||||
Object.keys(updatedMessage!.get('reacts')!['😄'].senders)[0],
|
||||
'sender pubkey should match'
|
||||
).to.be.equal(ourNumber);
|
||||
expect(updatedMessage!.get('reacts')!['😄'].count, 'count should be 1').to.be.equal(1);
|
||||
});
|
||||
|
||||
it('can remove a reaction from a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
|
||||
expect(reaction?.action, 'action should be 1').to.be.equal(1);
|
||||
|
||||
// Handling reaction
|
||||
const updatedMessage = await handleMessageReaction(
|
||||
reaction as SignalService.DataMessage.IReaction,
|
||||
ourNumber,
|
||||
false,
|
||||
originalMessage.get('id')
|
||||
);
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be
|
||||
.undefined;
|
||||
});
|
||||
|
||||
it('reactions are rate limited to 20 reactions per minute', async () => {
|
||||
// we have already sent 2 messages when this test runs
|
||||
for (let i = 0; i < 18; i++) {
|
||||
// Send reaction
|
||||
await sendMessageReaction(originalMessage.get('id'), '👍');
|
||||
}
|
||||
|
||||
let reaction = await sendMessageReaction(originalMessage.get('id'), '👎');
|
||||
|
||||
expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be
|
||||
.undefined;
|
||||
|
||||
clock = useFakeTimers(Date.now());
|
||||
|
||||
// Wait a miniute for the rate limit to clear
|
||||
clock.tick(1 * 60 * 1000);
|
||||
|
||||
reaction = await sendMessageReaction(originalMessage.get('id'), '👋');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 👋').to.be.equal('👋');
|
||||
expect(reaction?.action, 'action should be 0').to.be.equal(0);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
});
|
@ -0,0 +1,148 @@
|
||||
import { EmojiSet } from 'emoji-mart';
|
||||
|
||||
export const reactionLimit: number = 6;
|
||||
|
||||
export class RecentReactions {
|
||||
public items: Array<string> = [];
|
||||
|
||||
constructor(items: Array<string>) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
public push(item: string): void {
|
||||
if (this.size() === reactionLimit) {
|
||||
this.items.pop();
|
||||
}
|
||||
this.items.unshift(item);
|
||||
}
|
||||
|
||||
public pop(): string | undefined {
|
||||
return this.items.pop();
|
||||
}
|
||||
|
||||
public swap(index: number): void {
|
||||
const temp = this.items.splice(index, 1);
|
||||
this.push(temp[0]);
|
||||
}
|
||||
}
|
||||
|
||||
type BaseEmojiSkin = { unified: string; native: string };
|
||||
|
||||
export interface FixedBaseEmoji {
|
||||
id: string;
|
||||
name: string;
|
||||
keywords: Array<string>;
|
||||
skins: Array<BaseEmojiSkin>;
|
||||
version: number;
|
||||
search?: string;
|
||||
// props from emoji panel click event
|
||||
native?: string;
|
||||
aliases?: Array<string>;
|
||||
shortcodes?: string;
|
||||
unified?: string;
|
||||
}
|
||||
|
||||
export interface NativeEmojiData {
|
||||
categories: Array<{ id: string; emojis: Array<string> }>;
|
||||
emojis: Record<string, FixedBaseEmoji>;
|
||||
aliases: Record<string, string>;
|
||||
sheet: { cols: number; rows: number };
|
||||
ariaLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Types for EmojiMart 5 are currently broken these are a temporary fixes
|
||||
export interface FixedPickerProps {
|
||||
autoFocus?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
theme?: 'auto' | 'light' | 'dark' | undefined;
|
||||
perLine?: number | undefined;
|
||||
stickySearch?: boolean | undefined;
|
||||
searchPosition?: 'sticky' | 'static' | 'none' | undefined;
|
||||
emojiButtonSize?: number | undefined;
|
||||
emojiButtonRadius?: number | undefined;
|
||||
emojiButtonColors?: string | undefined;
|
||||
maxFrequentRows?: number | undefined;
|
||||
icons?: 'auto' | 'outline' | 'solid';
|
||||
set?: EmojiSet | undefined;
|
||||
emoji?: string | undefined;
|
||||
navPosition?: 'bottom' | 'top' | 'none' | undefined;
|
||||
showPreview?: boolean | undefined;
|
||||
previewEmoji?: boolean | undefined;
|
||||
noResultsEmoji?: string | undefined;
|
||||
previewPosition?: 'bottom' | 'top' | 'none' | undefined;
|
||||
skinTonePosition?: 'preview' | 'search' | 'none';
|
||||
onEmojiSelect?: (emoji: FixedBaseEmoji) => void;
|
||||
onClickOutside?: () => void;
|
||||
onKeyDown?: (event: any) => void;
|
||||
onAddCustomEmoji?: () => void;
|
||||
getImageURL?: () => void;
|
||||
getSpritesheetURL?: () => void;
|
||||
// Below here I'm currently unsure of usage
|
||||
// i18n?: PartialI18n | undefined;
|
||||
// style?: React.CSSProperties | undefined;
|
||||
// color?: string | undefined;
|
||||
// skin?: EmojiSkin | undefined;
|
||||
// defaultSkin?: EmojiSkin | undefined;
|
||||
// backgroundImageFn?: BackgroundImageFn | undefined;
|
||||
// sheetSize?: EmojiSheetSize | undefined;
|
||||
// emojisToShowFilter?(emoji: EmojiData): boolean;
|
||||
// showSkinTones?: boolean | undefined;
|
||||
// emojiTooltip?: boolean | undefined;
|
||||
// include?: CategoryName[] | undefined;
|
||||
// exclude?: CategoryName[] | undefined;
|
||||
// recent?: string[] | undefined;
|
||||
// /** NOTE: custom emoji are copied into a singleton object on every new mount */
|
||||
// custom?: CustomEmoji[] | undefined;
|
||||
// skinEmoji?: string | undefined;
|
||||
// notFound?(): React.Component;
|
||||
// notFoundEmoji?: string | undefined;
|
||||
// enableFrequentEmojiSort?: boolean | undefined;
|
||||
// useButton?: boolean | undefined;
|
||||
}
|
||||
|
||||
export enum Action {
|
||||
REACT = 0,
|
||||
REMOVE = 1,
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
// this is in fact a uint64 so we will have an issue
|
||||
id: number; // original message timestamp
|
||||
author: string;
|
||||
emoji: string;
|
||||
action: Action;
|
||||
}
|
||||
|
||||
// used for logic operations with reactions i.e reponses, db, etc.
|
||||
export type ReactionList = Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
index: number; // relies on reactsIndex in the message model
|
||||
senders: Record<string, string>; // <sender pubkey, messageHash or serverId>
|
||||
}
|
||||
>;
|
||||
|
||||
// used when rendering reactions to guarantee sorted order using the index
|
||||
export type SortedReactionList = Array<
|
||||
[string, { count: number; index: number; senders: Record<string, string> }]
|
||||
>;
|
||||
|
||||
export interface OpenGroupReaction {
|
||||
index: number;
|
||||
count: number;
|
||||
first: number;
|
||||
reactors: Array<string>;
|
||||
you: boolean;
|
||||
}
|
||||
|
||||
export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
|
||||
|
||||
export interface OpenGroupReactionResponse {
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
// Refactored from
|
||||
// https://stackoverflow.com/questions/2685911/is-there-a-way-to-round-numbers-into-a-reader-friendly-format-e-g-1-1k
|
||||
|
||||
const abbreviations = ['k', 'm', 'b', 't'];
|
||||
|
||||
export function abbreviateNumber(number: number, decimals: number = 2): string {
|
||||
let result = String(number);
|
||||
const d = Math.pow(10, decimals);
|
||||
|
||||
// Go through the array backwards, so we do the largest first
|
||||
for (let i = abbreviations.length - 1; i >= 0; i--) {
|
||||
// Convert array index to "1000", "1000000", etc
|
||||
const size = Math.pow(10, (i + 1) * 3);
|
||||
|
||||
// If the number is bigger or equal do the abbreviation
|
||||
if (size <= number) {
|
||||
// Here, we multiply by decimals, round, and then divide by decimals.
|
||||
// This gives us nice rounding to a particular decimal place.
|
||||
let n = Math.round((number * d) / size) / d;
|
||||
|
||||
// Handle special case where we round up to the next abbreviation
|
||||
if (n === 1000 && i < abbreviations.length - 1) {
|
||||
n = 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
result = String(n) + abbreviations[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@ -0,0 +1,251 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Data } from '../data/data';
|
||||
import { MessageModel } from '../models/message';
|
||||
import { SignalService } from '../protobuf';
|
||||
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { UserUtils } from '../session/utils';
|
||||
|
||||
import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction';
|
||||
import { getRecentReactions, saveRecentReations } from '../util/storage';
|
||||
|
||||
const rateCountLimit = 20;
|
||||
const rateTimeLimit = 60 * 1000;
|
||||
const latestReactionTimestamps: Array<number> = [];
|
||||
|
||||
/**
|
||||
* Retrieves the original message of a reaction
|
||||
*/
|
||||
const getMessageByReaction = async (
|
||||
reaction: SignalService.DataMessage.IReaction,
|
||||
isOpenGroup: boolean
|
||||
): Promise<MessageModel | null> => {
|
||||
let originalMessage = null;
|
||||
const originalMessageId = Number(reaction.id);
|
||||
const originalMessageAuthor = reaction.author;
|
||||
|
||||
if (isOpenGroup) {
|
||||
originalMessage = await Data.getMessageByServerId(originalMessageId);
|
||||
} else {
|
||||
const collection = await Data.getMessagesBySentAt(originalMessageId);
|
||||
originalMessage = collection.find((item: MessageModel) => {
|
||||
const messageTimestamp = item.get('sent_at');
|
||||
const author = item.get('source');
|
||||
return Boolean(
|
||||
messageTimestamp &&
|
||||
messageTimestamp === originalMessageId &&
|
||||
author &&
|
||||
author === originalMessageAuthor
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!originalMessage) {
|
||||
window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a Reaction Data Message, don't use for OpenGroups
|
||||
*/
|
||||
export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
||||
const found = await Data.getMessageById(messageId);
|
||||
if (found) {
|
||||
const conversationModel = found?.getConversation();
|
||||
if (!conversationModel) {
|
||||
window.log.warn(`Conversation for ${messageId} not found in db`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationModel.hasReactions()) {
|
||||
window.log.warn("This conversation doesn't have reaction support");
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
latestReactionTimestamps.push(timestamp);
|
||||
|
||||
if (latestReactionTimestamps.length > rateCountLimit) {
|
||||
const firstTimestamp = latestReactionTimestamps[0];
|
||||
if (timestamp - firstTimestamp < rateTimeLimit) {
|
||||
latestReactionTimestamps.pop();
|
||||
return;
|
||||
} else {
|
||||
latestReactionTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const isOpenGroup = Boolean(found?.get('isPublic'));
|
||||
const id = (isOpenGroup && found.get('serverId')) || Number(found.get('sent_at'));
|
||||
const me =
|
||||
(isOpenGroup && getUsBlindedInThatServer(conversationModel)) ||
|
||||
UserUtils.getOurPubKeyStrFromCache();
|
||||
const author = found.get('source');
|
||||
let action: Action = Action.REACT;
|
||||
|
||||
const reacts = found.get('reacts');
|
||||
if (
|
||||
reacts &&
|
||||
Object.keys(reacts).includes(emoji) &&
|
||||
Object.keys(reacts[emoji].senders).includes(me)
|
||||
) {
|
||||
window.log.info('found matching reaction removing it');
|
||||
action = Action.REMOVE;
|
||||
} else {
|
||||
const reactions = getRecentReactions();
|
||||
if (reactions) {
|
||||
await updateRecentReactions(reactions, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
const reaction = {
|
||||
id,
|
||||
author,
|
||||
emoji,
|
||||
action,
|
||||
};
|
||||
|
||||
await conversationModel.sendReaction(messageId, reaction);
|
||||
|
||||
window.log.info(
|
||||
`You ${action === Action.REACT ? 'added' : 'removed'} a`,
|
||||
emoji,
|
||||
'reaction for message',
|
||||
id
|
||||
);
|
||||
return reaction;
|
||||
} else {
|
||||
window.log.warn(`Message ${messageId} not found in db`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle reactions on the client by updating the state of the source message
|
||||
*/
|
||||
export const handleMessageReaction = async (
|
||||
reaction: SignalService.DataMessage.IReaction,
|
||||
sender: string,
|
||||
isOpenGroup: boolean,
|
||||
messageId?: string
|
||||
) => {
|
||||
if (!reaction.emoji) {
|
||||
window?.log?.warn(`There is no emoji for the reaction ${messageId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMessage = await getMessageByReaction(reaction, isOpenGroup);
|
||||
if (!originalMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reacts: ReactionList = originalMessage.get('reacts') ?? {};
|
||||
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: {} };
|
||||
const details = reacts[reaction.emoji] ?? {};
|
||||
const senders = Object.keys(details.senders);
|
||||
|
||||
window.log.info(
|
||||
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
|
||||
reaction.emoji
|
||||
} reaction`
|
||||
);
|
||||
|
||||
switch (reaction.action) {
|
||||
case SignalService.DataMessage.Reaction.Action.REACT:
|
||||
if (senders.includes(sender) && details.senders[sender] !== '') {
|
||||
window?.log?.info(
|
||||
'Received duplicate message reaction. Dropping it. id:',
|
||||
details.senders[sender]
|
||||
);
|
||||
return;
|
||||
}
|
||||
details.senders[sender] = messageId ?? '';
|
||||
break;
|
||||
case SignalService.DataMessage.Reaction.Action.REMOVE:
|
||||
default:
|
||||
if (senders.length > 0) {
|
||||
if (senders.indexOf(sender) >= 0) {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete details.senders[sender];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = Object.keys(details.senders).length;
|
||||
if (count > 0) {
|
||||
reacts[reaction.emoji].count = count;
|
||||
reacts[reaction.emoji].senders = details.senders;
|
||||
|
||||
// sorting for open groups convos is handled by SOGS
|
||||
if (!isOpenGroup && details && details.index === undefined) {
|
||||
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
|
||||
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
|
||||
}
|
||||
} else {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete reacts[reaction.emoji];
|
||||
}
|
||||
|
||||
originalMessage.set({
|
||||
reacts: !isEmpty(reacts) ? reacts : undefined,
|
||||
});
|
||||
|
||||
await originalMessage.commit();
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle all updates to messages reactions from the SOGS API
|
||||
*/
|
||||
export const handleOpenGroupMessageReactions = async (
|
||||
reactions: OpenGroupReactionList,
|
||||
serverId: number
|
||||
) => {
|
||||
const originalMessage = await Data.getMessageByServerId(serverId);
|
||||
if (!originalMessage) {
|
||||
window?.log?.warn(`Cannot find the original reacted message ${serverId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(reactions)) {
|
||||
if (originalMessage.get('reacts')) {
|
||||
originalMessage.set({
|
||||
reacts: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const reacts: ReactionList = {};
|
||||
Object.keys(reactions).forEach(key => {
|
||||
const emoji = decodeURI(key);
|
||||
const senders: Record<string, string> = {};
|
||||
reactions[key].reactors.forEach(reactor => {
|
||||
senders[reactor] = String(serverId);
|
||||
});
|
||||
reacts[emoji] = { count: reactions[key].count, index: reactions[key].index, senders };
|
||||
});
|
||||
|
||||
originalMessage.set({
|
||||
reacts,
|
||||
});
|
||||
}
|
||||
|
||||
await originalMessage.commit();
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
export const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
|
||||
window?.log?.info('updating recent reactions with', newReaction);
|
||||
const recentReactions = new RecentReactions(reactions);
|
||||
const foundIndex = recentReactions.items.indexOf(newReaction);
|
||||
if (foundIndex === 0) {
|
||||
return;
|
||||
}
|
||||
if (foundIndex > 0) {
|
||||
recentReactions.swap(foundIndex);
|
||||
} else {
|
||||
recentReactions.push(newReaction);
|
||||
}
|
||||
await saveRecentReations(recentReactions.items);
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
export const readableList = (
|
||||
arr: Array<string>,
|
||||
conjunction: string = '&',
|
||||
limit: number = 3
|
||||
): string => {
|
||||
if (arr.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const count = arr.length;
|
||||
switch (count) {
|
||||
case 1:
|
||||
return arr[0];
|
||||
default:
|
||||
let result = '';
|
||||
let others = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (others === 0 && i === count - 1 && i < limit) {
|
||||
result += ` ${conjunction} `;
|
||||
} else if (i !== 0 && i < limit) {
|
||||
result += ', ';
|
||||
} else if (i >= limit) {
|
||||
others++;
|
||||
}
|
||||
|
||||
if (others === 0) {
|
||||
result += arr[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (others > 0) {
|
||||
result += ` ${conjunction} ${others} ${
|
||||
others > 1
|
||||
? window.i18n('readableListCounterPlural')
|
||||
: window.i18n('readableListCounterSingular')
|
||||
}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue