Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 99 additions & 12 deletions app/containers/message/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import React from 'react';
import { Keyboard } from 'react-native';
import { Alert, Keyboard } from 'react-native';
import { dequal } from 'dequal';

import Message from './Message';
import MessageContext from './Context';
import { debounce } from '../../lib/methods/helpers';
import { getMessageTranslation } from './utils';
import { type TSupportedThemes, withTheme } from '../../theme';
import openLink from '../../lib/methods/helpers/openLink';
import { type IAttachment, type TAnyMessageModel, type TGetCustomEmoji } from '../../definitions';
import { type IReaction, type IAttachment, type TAnyMessageModel, type TGetCustomEmoji } from '../../definitions';
import { type IRoomInfoParam } from '../../views/SearchMessagesView';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/constants/keys';
import { messagesStatus } from '../../lib/constants/messagesStatus';
import MessageSeparator from '../MessageSeparator';
import i18n from '../../i18n';

interface IMessageContainerProps {
item: TAnyMessageModel;
Expand Down Expand Up @@ -39,7 +41,7 @@ interface IMessageContainerProps {
highlighted?: boolean;
getCustomEmoji: TGetCustomEmoji;
onLongPress?: (item: TAnyMessageModel) => void;
onReactionPress?: (emoji: string, id: string) => void;
onReactionPress?: (emoji: string, id: string) => Promise<boolean>;
onEncryptedPress?: () => void;
onDiscussionPress?: (item: TAnyMessageModel) => void;
onThreadPress?: (item: TAnyMessageModel) => void;
Expand Down Expand Up @@ -67,6 +69,8 @@ interface IMessageContainerProps {

interface IMessageContainerState {
isManualUnignored: boolean;
// for optimistic reaction updates
proxyReactions?: IReaction[];
}

class MessageContainer extends React.Component<IMessageContainerProps, IMessageContainerState> {
Expand All @@ -80,7 +84,10 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
theme: 'light' as TSupportedThemes
};

state = { isManualUnignored: false };
/**
* set undefined when we are using value from server
*/
state = { isManualUnignored: false, proxyReactions: undefined };

private subscription?: Function;

Expand All @@ -92,13 +99,14 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
// experimentalSubscribe(subscriber: (isDeleted: boolean) => void, debugInfo?: any): Unsubscribe
// @ts-ignore
this.subscription = item.experimentalSubscribe(() => {
this.forceUpdate();
this.setState({ proxyReactions: undefined });
});
}
}

shouldComponentUpdate(nextProps: IMessageContainerProps, nextState: IMessageContainerState) {
const { isManualUnignored } = this.state;
const { isManualUnignored, proxyReactions } = this.state;

const {
threadBadgeColor,
isIgnored,
Expand All @@ -108,9 +116,18 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
autoTranslateLanguage,
isBeingEdited,
showUnreadSeparator,
dateSeparator
dateSeparator,
item
} = this.props;

// optimistic UI updates
if (!dequal(nextState.proxyReactions, proxyReactions)) {
return true;
}

if (!dequal(nextProps.item.reactions, item.reactions)) {
return true;
}
if (nextProps.showUnreadSeparator !== showUnreadSeparator) {
return true;
}
Expand Down Expand Up @@ -205,11 +222,78 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
}
};

onReactionPress = (emoji: string) => {
const { onReactionPress, item } = this.props;
if (onReactionPress) {
onReactionPress(emoji, item.id);
// proxy reaction utility functions
private removeUserFromReaction = (reaction: IReaction, username: string): IReaction | null => {
const usernames = reaction.usernames.filter(u => u !== username);
const names = usernames;

if (usernames.length === 0) return null; // remove entirely

return { ...reaction, usernames, names };
};

private addUserToReaction = (reaction: IReaction, username: string): IReaction => ({
...reaction,
usernames: [...reaction.usernames, username],
names: [...reaction.usernames, username]
});

private toggleReactionInList = (reactions: IReaction[], emoji: string, username: string): IReaction[] => {
let updated = [...reactions];
const index = updated.findIndex(r => r.emoji === emoji);

if (index === -1) {
// New reaction
updated.push({ _id: `${emoji}-${Date.now()}-${Math.random()}`, emoji, usernames: [username], names: [username] });
return updated;
}

const alreadyReacted = updated[index].usernames.includes(username);

if (alreadyReacted) {
const next = this.removeUserFromReaction(updated[index], username);

if (next === null) {
updated = updated.filter((_, i) => i !== index);
return updated;
}

updated = updated.map((r, i) => (i === index ? next : r));
return updated;
}

updated[index] = this.addUserToReaction(updated[index], username);
return updated;
};

private applyOptimisticReaction = (emoji: string, username: string) => {
this.setState(prev => {
const current = prev.proxyReactions ?? this.props.item.reactions ?? [];
return { proxyReactions: this.toggleReactionInList(current, emoji, username) };
});
};

private rollbackReaction = () => {
Alert.alert(i18n.t('Error'), i18n.t('Reaction_Failed'));
this.setState({ proxyReactions: undefined });
};

onReactionPress = async (emoji: string) => {
const {
onReactionPress,
item,
user: { username }
} = this.props;
if (!onReactionPress) return;

// proxy reactions first for instant update
this.applyOptimisticReaction(emoji, username);

// then update on server
const success = await onReactionPress(emoji, item.id);

if (!success) return this.rollbackReaction();
if (!this.subscription) this.setState({ proxyReactions: undefined });
};

onReactionLongPress = () => {
Expand Down Expand Up @@ -387,7 +471,6 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
ts,
attachments,
urls,
reactions,
t,
avatar,
emoji,
Expand All @@ -413,6 +496,10 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
pinned
} = item;

// extract reactions later for optimistic updates
const serverReactions = item.reactions;
const reactions = this.state.proxyReactions ?? serverReactions;

let message = msg;
let isTranslated = false;
const otherUserMessage = u?.username !== user?.username;
Expand Down
2 changes: 2 additions & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@
"Enter_E2EE_Password_description": "Enter your E2EE password to view and send encrypted messages.\n\nPassword need to be entered on each device.",
"Enter_manually": "Enter manually",
"Enter_the_code": "Enter the code we just emailed you.",
"Error": "Error",
"Error_Download_file": "Error while downloading file",
"Error_incorrect_password": "Incorrect password",
"Error_play_video": "There was an error while playing this video",
Expand Down Expand Up @@ -687,6 +688,7 @@
"Queued_chats": "Queued chats",
"Quote": "Quote",
"React_with_emojjname": "React with {{emojiName}}",
"Reaction_Failed": "Failed to React",
"Reactions_are_disabled": "Reactions are disabled",
"Reactions_are_enabled": "Reactions are enabled",
"Read_External_Permission": "Read media permission",
Expand Down
2 changes: 2 additions & 0 deletions app/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"Encryption_error_title": "Contraseña incorrecta",
"Enter_E2EE_Password_description": "Ingrese su contraseña E2EE para ver y enviar mensajes cifrados.\n\nLa contraseña debe ingresarse en cada dispositivo.",
"Enter_manually": "Ingresar manualmente",
"Error": "Error",
"Error_incorrect_password": "Contraseña incorrecta",
"Error_prefix": "Error: {{mensaje}}",
"Error_uploading": "Error en la subida",
Expand Down Expand Up @@ -325,6 +326,7 @@
"Push_Notifications_Alert_Info": "Estas notificaciones se le entregan cuando la aplicación no está abierta",
"Quote": "Citar",
"React_with_emojjname": "Reacciona con {{emojiName}}",
"Reaction_Failed": "No se pudo reaccionar",
"Reactions_are_disabled": "Las reacciones están desactivadas",
"Reactions_are_enabled": "Las reacciones están activadas",
"Read_Only": "Sólo lectura ",
Expand Down
2 changes: 2 additions & 0 deletions app/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
"Enter_E2EE_Password_description": "Digite sua senha E2EE para visualizar e enviar mensagens criptografadas.\n\nA senha precisa ser inserida em cada dispositivo.",
"Enter_manually": "Inserir manualmente",
"Enter_the_code": "Insira o código que acabamos de enviar por e-mail.",
"Error": "Erro",
"Error_Download_file": "Erro ao baixar o arquivo",
"Error_incorrect_password": "Senha incorreta",
"Error_play_video": "Houve um erro ao reproduzir esse vídeo",
Expand Down Expand Up @@ -675,6 +676,7 @@
"Queued_chats": "Bate-papos na fila",
"Quote": "Citar",
"React_with_emojjname": "Reagir com {{emojiName}}",
"Reaction_Failed": "Não reagiu",
"Reactions_are_disabled": "Reagir está desabilitado",
"Reactions_are_enabled": "Reagir está habilitado",
"Read_External_Permission": "Permissão de acesso à arquivos",
Expand Down
2 changes: 2 additions & 0 deletions app/views/RoomView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,9 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
Review.pushPositiveEvent();
} catch (e) {
log(e);
return false;
}
return true;
};

onReactionLongPress = (message: TAnyMessageModel) => {
Expand Down