From ea886387b062ee3328d990e021ef6c9e4e3dd55c Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sun, 14 Apr 2024 13:13:54 +0300 Subject: [PATCH 01/13] Add test for delete-account - Test not actually working currently, it was just manual check. - Would be quite cumbersome automated test, to wait --- .detoxrc.js | 4 ++-- e2e/chatTest.spec.ts | 48 +++++++++++++++++++++++++++++++++++++++++++- e2e/helpers.ts | 12 +++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.detoxrc.js b/.detoxrc.js index fd7b9c5f..91f7680a 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -20,7 +20,7 @@ module.exports = { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/ylitse.app', build: - "xcodebuild -workspace ios/ylitse.xcworkspace -configuration Debug -scheme ylitse -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.4' -derivedDataPath ios/build", + "xcodebuild -workspace ios/ylitse.xcworkspace -configuration Debug -scheme ylitse -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' -derivedDataPath ios/build", }, 'android.debug': { type: 'android.apk', @@ -32,7 +32,7 @@ module.exports = { devices: { 'ios.simulator': { type: 'ios.simulator', - device: { type: 'iPhone 14' }, + device: { type: 'iPhone 15' }, }, 'android.emulator': { type: 'android.emulator', diff --git a/e2e/chatTest.spec.ts b/e2e/chatTest.spec.ts index 098790af..499291e7 100644 --- a/e2e/chatTest.spec.ts +++ b/e2e/chatTest.spec.ts @@ -5,7 +5,10 @@ import accountFixtures from './fixtures/accounts.json'; import { APISignUpMentee, APISignUpMentor, + APIGetSendInfo, + APISendMessage, APIDeleteAccounts, + APIDeleteAccount, waitAndTypeText, signIn, forceLogout, @@ -21,7 +24,7 @@ describe('Chat', () => { await device.reloadReactNative(); }); - it('with new mentor', async () => { + xit('with new mentor', async () => { const mentee = accountFixtures.mentees[0]; await APISignUpMentee(mentee); const mentor = accountFixtures.mentors[0]; @@ -59,4 +62,47 @@ describe('Chat', () => { await expect(element(by.text(message_from_mentee))).toBeVisible(); await expect(element(by.text(message_from_mentor))).toBeVisible(); }); + + it('if buddy with most recent message deletes account, can receive still messages from other users', async () => { + const mentee = accountFixtures.mentees[0]; + await APISignUpMentee(mentee); + const mentor = accountFixtures.mentors[0]; + await APISignUpMentor(mentor); + + // mentee sends a msg to mentor + const { + sender_id: menteeId, + sender_info: mentee_info, + recipient_id: mentorId, + senderHeaders: menteeHeaders, + } = await APIGetSendInfo(mentee, mentor); + await APISendMessage({ + sender_id: menteeId, + recipient_id: mentorId, + content: 'Hi first', + headers: menteeHeaders, + }); + + await signIn(mentor); + await element(by.id('tabs.chats')).tap(); + await element(by.text(mentee.displayName)).tap(); + await element(by.id('chat.back.button')).tap(); + + // delete mentee account + await APIDeleteAccount(mentee_info.account_id, menteeHeaders) + + // new mentee + const newMentee = accountFixtures.mentees[1]; + await APISignUpMentee(newMentee); + const { + sender_id: newMenteeId, + senderHeaders: newMenteeHeaders, + } = await APIGetSendInfo(newMentee, mentor); + await APISendMessage({ + sender_id: newMenteeId, + recipient_id: mentorId, + content: 'Hi second', + headers: newMenteeHeaders, + }); + }); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 65f2e1ba..3f548fcd 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -208,6 +208,17 @@ export async function APIDeleteAccounts() { } } +/** + * Makes HTTP API calls to delete user + */ +export async function APIDeleteAccount(id: string, headers: Record) { + + await fetch(`${API_URL}/accounts/${id}`, { + method: 'DELETE', + headers, + }); +} + /** * SignUp new mentee */ @@ -370,6 +381,7 @@ export async function APIGetSendInfo(sender: any, reciever: any) { return { sender_id: senderInfo.id, + sender_info: senderInfo, recipient_id: recieverInfo.id, senderHeaders: toHeader(accessTokenSender), recieverHeaders: toHeader(accessTokenReciever), From c04b9f37a09325b0779cba88deb82d3ec013959d Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sun, 21 Apr 2024 15:53:57 +0300 Subject: [PATCH 02/13] Mark messages unseen when fully visible - Also fix selectors unseen check: check all messages, not just last message --- .../Chat/MessageList/MemoizedRenderItem.tsx | 15 ++++++- src/Screens/Main/Chat/MessageList/Message.tsx | 19 +++----- src/Screens/Main/Chat/MessageList/index.tsx | 43 ++++++++++++++----- src/Screens/Main/Chat/index.tsx | 4 +- src/state/actions/regular.ts | 2 +- src/state/reducers/markSeen.ts | 41 ++++++++++++------ src/state/reducers/messages.ts | 38 +++++++--------- 7 files changed, 99 insertions(+), 63 deletions(-) diff --git a/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx b/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx index c3867c9c..fbc0f203 100644 --- a/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx +++ b/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import * as RN from 'react-native'; -import Message from './Message'; +import Message, { MessageProps } from './Message'; import DateBubble from './DateBubble'; import Spinner from 'src/Screens/components/Spinner'; import { Renderable } from '.'; @@ -27,7 +27,18 @@ const RenderItem: React.FC = ({ item }) => { const equalProps = ( prevProps: React.ComponentProps, nextProps: React.ComponentProps, -) => prevProps.item.id === nextProps.item.id; +) => { + if ( + Object.prototype.hasOwnProperty.call(nextProps.item, 'isSeen') && + Object.prototype.hasOwnProperty.call(prevProps.item, 'isSeen') + ) { + const prev = prevProps.item as MessageProps; + const next = nextProps.item as MessageProps; + return prev.id === next.id && prev.isSeen === next.isSeen; + } + + return prevProps.item.id === nextProps.item.id; +}; export const MemoizedRenderItem = React.memo(RenderItem, equalProps); diff --git a/src/Screens/Main/Chat/MessageList/Message.tsx b/src/Screens/Main/Chat/MessageList/Message.tsx index 2dda67a0..2470705d 100644 --- a/src/Screens/Main/Chat/MessageList/Message.tsx +++ b/src/Screens/Main/Chat/MessageList/Message.tsx @@ -1,10 +1,5 @@ import React from 'react'; import RN from 'react-native'; -import * as redux from 'redux'; -import { useDispatch } from 'react-redux'; - -import { markSeen } from '../../../../state/reducers/markSeen'; -import * as actions from '../../../../state/actions'; import colors from '../../../components/colors'; import fonts from '../../../components/fonts'; @@ -16,20 +11,20 @@ export type MessageProps = { type: 'Message'; value: messageApi.Message; id: string; + isSeen: boolean; }; const Message = ({ value: message }: MessageProps) => { const { content, sentTime, type } = message; - const dispatch = useDispatch>(); - React.useEffect(() => { - if (!message.isSeen && message.type === 'Received') { - dispatch(markSeen({ message })); - } - }, []); const bubbleStyle = type === 'Received' ? styles.leftBubble : styles.rightBubble; + const isSeenTemp = + type === 'Received' && !message.isSeen + ? { borderWidth: 2, borderColor: 'red' } + : { borderWidth: 2, borderColor: 'green' }; + const addZero = (n: number) => (n < 10 ? `0${n}` : `${n}`); const date = new Date(sentTime); const hours = addZero(date.getHours()); @@ -37,7 +32,7 @@ const Message = ({ value: message }: MessageProps) => { const timeText = `${hours}:${minutes}`; return ( - + {content} diff --git a/src/Screens/Main/Chat/MessageList/index.tsx b/src/Screens/Main/Chat/MessageList/index.tsx index a4465c9a..246a66ca 100644 --- a/src/Screens/Main/Chat/MessageList/index.tsx +++ b/src/Screens/Main/Chat/MessageList/index.tsx @@ -1,8 +1,10 @@ import React from 'react'; import RN from 'react-native'; +import { useDispatch } from 'react-redux'; import * as localization from '../../../../localization'; +import { markSeen } from '../../../../state/reducers/markSeen'; import * as messageApi from '../../../../api/messages'; import { MessageProps } from './Message'; @@ -55,6 +57,7 @@ export function toRenderable( type: 'Message' as const, value: m, id: m.messageId, + isSeen: m.isSeen, }; const date = getDate(next.value.sentTime); const nextDate = { type: 'Date' as const, value: date, id: date }; @@ -81,13 +84,14 @@ export function toRenderable( : messageList; } -const MessageList = ({ +export const MessageList = ({ messageList, getPreviousMessages, isLoading, }: Props) => { const messages = toRenderable(messageList, isLoading); const previousItem = messageList.length > 0 ? messageList[0].messageId : '0'; + const dispatch = useDispatch(); const getPreviousMessagesIfNotLoading = () => { if (isLoading) { @@ -97,11 +101,39 @@ const MessageList = ({ getPreviousMessages(previousItem); }; + type ViewArgs = { + viewableItems: Array; + changed: Array; + }; + const handleViewableChanged = ({ changed }: ViewArgs) => { + // TODO: Helper here, decode the changed maybe + const unSeenMessagesOnScreen = changed + .filter(item => item.isViewable) + .filter( + ({ item }) => + !item.isSeen && item.value && item.value.type === 'Received', + ) + .map(({ item }) => item.value); + + console.log('changed', unSeenMessagesOnScreen); + + dispatch(markSeen({ messages: unSeenMessagesOnScreen })); + }; + + const viewabilityConfig: RN.ViewabilityConfig = { + itemVisiblePercentThreshold: 100, + minimumViewTime: 1000, + }; + const viewabilityConfigCallbackPairs = React.useRef([ + { viewabilityConfig, onViewableItemsChanged: handleViewableChanged }, + ]); + return ( } + viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} keyExtractor={item => item.id} inverted={true} onEndReachedThreshold={0.01} @@ -110,15 +142,6 @@ const MessageList = ({ ); }; -const equalProps = ( - prevProps: React.ComponentProps, - nextProps: React.ComponentProps, -) => - prevProps.messageList.length === nextProps.messageList.length && - prevProps.isLoading === nextProps.isLoading; - -export const MemoizedMessageList = React.memo(MessageList, equalProps); - const styles = RN.StyleSheet.create({ scrollContent: { paddingHorizontal: 24, diff --git a/src/Screens/Main/Chat/index.tsx b/src/Screens/Main/Chat/index.tsx index bd6bc9bf..485a9895 100644 --- a/src/Screens/Main/Chat/index.tsx +++ b/src/Screens/Main/Chat/index.tsx @@ -16,7 +16,7 @@ import { isDevice } from '../../../lib/isDevice'; import Title from './Title'; import Input from './Input'; -import { MemoizedMessageList } from './MessageList'; +import { MessageList } from './MessageList'; import DropDown, { DropDownItem } from '../../components/DropDownMenu'; import Modal from '../../components/Modal'; import { dialogProperties, changeChatStatusOptions } from './chatProperties'; @@ -185,7 +185,7 @@ const Chat = ({ navigation, route }: Props) => { messageId="main.chat.send.failure" /> )} - ; - 'messages/markSeen': { message: messageApi.Message }; + 'messages/markSeen': { messages: Array }; 'messages/markSeen/end': undefined; 'buddies/completed': Result; diff --git a/src/state/reducers/markSeen.ts b/src/state/reducers/markSeen.ts index 198522c9..e68d40b5 100644 --- a/src/state/reducers/markSeen.ts +++ b/src/state/reducers/markSeen.ts @@ -14,25 +14,40 @@ export const reducer: automaton.Reducer = ( action, ) => { switch (action.type) { - case 'messages/markSeen': - const message = action.payload.message; - const id = message.messageId; - - if (id in state || message.isSeen || message.type === 'Sent') { - return state; + case 'messages/markSeen': { + const hasMessages = action.payload.messages.length > 0; + if (!hasMessages) { + return automaton.loop(state, { + type: 'messages/markSeen/end', + payload: undefined, + }); } - const markSeenTask = api.markSeen(action.payload.message); + const [first, ...messages] = action.payload.messages; + + console.log('markSeen action for message', first.content); + const nextState = + first.messageId in state || first.isSeen || first.type === 'Sent' + ? state + : { ...state, [first.messageId]: true }; + + const markSeenTask = api.markSeen(first); return automaton.loop( - { ...state, [id]: true }, - withToken(markSeenTask, () => ({ - type: 'messages/markSeen/end', - payload: undefined, - })), + nextState, + // withToken(markSeenTask, () => ({ + // type: 'messages/markSeen', + // payload: { messages }, + // })), + { + type: 'messages/markSeen', + payload: { messages }, + }, ); + } - default: + default: { return state; + } } }; diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index 83c1f094..d952b0ee 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -159,9 +159,14 @@ export const reducer: automaton.Reducer = ( } case 'messages/markSeen': { - const { messageId, buddyId, type, isSeen } = action.payload.message; + const hasMessages = action.payload.messages.length > 0; + if (!hasMessages) { + return state; + } + + const [first, ..._rest] = action.payload.messages; - if (type === 'Sent' || isSeen === true) { + if (first.type === 'Sent' || first.isSeen === true) { return state; } @@ -170,14 +175,14 @@ export const reducer: automaton.Reducer = ( : {}; const updatedMessage = { - ...action.payload.message, + ...first, isSeen: true, }; const updatedMessageRecord = { - [buddyId]: { - ...oldMessages[buddyId], - [messageId]: updatedMessage, + [first.buddyId]: { + ...oldMessages[first.buddyId], + [first.messageId]: updatedMessage, }, }; @@ -225,17 +230,8 @@ export const ordMessage: ord.Ord = ord.fromCompare((a, b) => export const hasUnseen: ( buddyId: string, ) => (appState: types.AppState) => boolean = buddyId => appState => - pipe( - getMessagesByBuddyId(buddyId)(appState), - array.sort(ordMessage), - array.last, - O.map( - ({ type, isSeen }) => - type === 'Received' && - isSeen === false && - !getIsBanned(buddyId)(appState), - ), - O.fold(() => false, identity), + pipe(getMessagesByBuddyId(buddyId)(appState), messages => + messages.some(({ type, isSeen }) => type === 'Received' && !isSeen), ); export const isAnyMessageUnseen = (appState: types.AppState) => @@ -259,17 +255,13 @@ export const hasUnseenMessagesOfStatus = export const hasUnseenMessagesByType = (buddyId: string, chatType: buddyApi.ChatStatus) => (appState: types.AppState) => - pipe( - getMessagesByBuddyId(buddyId)(appState), - array.sort(ordMessage), - array.last, - O.map( + pipe(getMessagesByBuddyId(buddyId)(appState), messages => + messages.some( ({ type, isSeen }) => type === 'Received' && !isSeen && getBuddyStatus(buddyId)(appState) === chatType, ), - O.fold(() => false, identity), ); export const getMessage = ( { messages: messageState }: types.AppState, From d860fb7c1af2fd28d70b2e14e3c37adf9e38bae5 Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sun, 28 Apr 2024 09:42:01 +0300 Subject: [PATCH 03/13] Fetch more messages if oldest unread - If we are fetching older messages for buddy, then keep fetching until oldest is not unread --- src/api/messages.ts | 10 ++++++++++ src/state/reducers/messages.ts | 24 ++++++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/api/messages.ts b/src/api/messages.ts index b6f7cfa6..4c53b973 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -205,6 +205,16 @@ const sortSentTime = (a: Message, b: Message) => { return a.sentTime < b.sentTime ? -1 : 1; }; +export const getIsOldestFetchedMessageUnread = ( + fetchedBuddyMessages: Record, +) => { + const sorted = Object.keys(fetchedBuddyMessages) + .map(msgId => fetchedBuddyMessages[msgId]) + .sort(sortSentTime); + + return !sorted[sorted.length - 1].isSeen; +}; + export const extractMostRecentId = (messages: MessageMapping) => { const flattenedMessages = Object.keys(messages).reduce< Record diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index d952b0ee..d5079b83 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -93,16 +93,32 @@ export const reducer: automaton.Reducer = ( const previousMsgId = messageApi.extractMostRecentId(nextMessages); - const [pollingParams, nextPollingParams] = messageApi.getNextParams( + const isFetchingOlderMessagesAndIsOldestFetchedUnread = + state.currentParams.type === 'OlderThan' + ? messageApi.getIsOldestFetchedMessageUnread( + newMessages[state.currentParams.buddyId], + ) + : false; + + const [first, rest] = messageApi.getNextParams( action.payload, state.pollingQueue, state.currentParams, previousMsgId, ); + const { nextFetch, nextCurrent, nextQueue } = + isFetchingOlderMessagesAndIsOldestFetchedUnread + ? { + nextCurrent: state.currentParams, + nextFetch: state.currentParams, + nextQueue: [first, ...rest], + } + : { nextCurrent: first, nextFetch: first, nextQueue: rest }; + const nextCmd = withToken( flow( - messageApi.fetchMessages(pollingParams), + messageApi.fetchMessages(nextFetch), T.delay(config.messageFetchDelay), ), actions.make('messages/get/completed'), @@ -113,8 +129,8 @@ export const reducer: automaton.Reducer = ( ...state, messages: RD.success(nextMessages), previousMsgId, - pollingQueue: nextPollingParams, - currentParams: pollingParams, + pollingQueue: nextQueue, + currentParams: nextCurrent, }, nextCmd, ); From ffe73e1f799568cd4bf189c8100be30f5327738b Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sat, 4 May 2024 10:29:50 +0300 Subject: [PATCH 04/13] Fix eslint/ts issues --- .../Main/Chat/MessageList/MemoizedRenderItem.tsx | 1 + src/Screens/Main/Chat/MessageList/index.tsx | 2 ++ src/state/reducers/markSeen.ts | 10 ++++------ src/state/reducers/messages.ts | 3 ++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx b/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx index fbc0f203..e305980d 100644 --- a/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx +++ b/src/Screens/Main/Chat/MessageList/MemoizedRenderItem.tsx @@ -34,6 +34,7 @@ const equalProps = ( ) { const prev = prevProps.item as MessageProps; const next = nextProps.item as MessageProps; + return prev.id === next.id && prev.isSeen === next.isSeen; } diff --git a/src/Screens/Main/Chat/MessageList/index.tsx b/src/Screens/Main/Chat/MessageList/index.tsx index 246a66ca..332a78d2 100644 --- a/src/Screens/Main/Chat/MessageList/index.tsx +++ b/src/Screens/Main/Chat/MessageList/index.tsx @@ -105,6 +105,7 @@ export const MessageList = ({ viewableItems: Array; changed: Array; }; + const handleViewableChanged = ({ changed }: ViewArgs) => { // TODO: Helper here, decode the changed maybe const unSeenMessagesOnScreen = changed @@ -124,6 +125,7 @@ export const MessageList = ({ itemVisiblePercentThreshold: 100, minimumViewTime: 1000, }; + const viewabilityConfigCallbackPairs = React.useRef([ { viewabilityConfig, onViewableItemsChanged: handleViewableChanged }, ]); diff --git a/src/state/reducers/markSeen.ts b/src/state/reducers/markSeen.ts index e68d40b5..bbe04529 100644 --- a/src/state/reducers/markSeen.ts +++ b/src/state/reducers/markSeen.ts @@ -16,6 +16,7 @@ export const reducer: automaton.Reducer = ( switch (action.type) { case 'messages/markSeen': { const hasMessages = action.payload.messages.length > 0; + if (!hasMessages) { return automaton.loop(state, { type: 'messages/markSeen/end', @@ -26,6 +27,7 @@ export const reducer: automaton.Reducer = ( const [first, ...messages] = action.payload.messages; console.log('markSeen action for message', first.content); + const nextState = first.messageId in state || first.isSeen || first.type === 'Sent' ? state @@ -35,14 +37,10 @@ export const reducer: automaton.Reducer = ( return automaton.loop( nextState, - // withToken(markSeenTask, () => ({ - // type: 'messages/markSeen', - // payload: { messages }, - // })), - { + withToken(markSeenTask, () => ({ type: 'messages/markSeen', payload: { messages }, - }, + })), ); } diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index d5079b83..cb3b7491 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -16,7 +16,7 @@ import * as actions from '../actions'; import * as types from '../types'; import { withToken } from './accessToken'; -import { getIsBanned, getBuddyStatus } from '../selectors'; +import { getBuddyStatus } from '../selectors'; export type State = types.AppState['messages']; export type LoopState = actions.LS; @@ -176,6 +176,7 @@ export const reducer: automaton.Reducer = ( case 'messages/markSeen': { const hasMessages = action.payload.messages.length > 0; + if (!hasMessages) { return state; } From 5902ee3975aeea18bb313c5e521e2e14cd24caff Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sat, 4 May 2024 11:15:27 +0300 Subject: [PATCH 05/13] Fix logic for InitialMessages OlderThan - It is possible that InitialMessages should spawn new requests for fetching messages. - For example Mr. A has sent Mr. B 23 messages. If B fetches initially 10, it should also fetch the 13 unread! --- e2e/chatTest.spec.ts | 48 +--------------------------------- e2e/helpers.ts | 14 +++++----- src/api/messages.ts | 41 +++++++++++++++++++++++------ src/state/reducers/markSeen.ts | 4 +++ src/state/reducers/messages.ts | 38 +++++++++++++-------------- 5 files changed, 64 insertions(+), 81 deletions(-) diff --git a/e2e/chatTest.spec.ts b/e2e/chatTest.spec.ts index 499291e7..098790af 100644 --- a/e2e/chatTest.spec.ts +++ b/e2e/chatTest.spec.ts @@ -5,10 +5,7 @@ import accountFixtures from './fixtures/accounts.json'; import { APISignUpMentee, APISignUpMentor, - APIGetSendInfo, - APISendMessage, APIDeleteAccounts, - APIDeleteAccount, waitAndTypeText, signIn, forceLogout, @@ -24,7 +21,7 @@ describe('Chat', () => { await device.reloadReactNative(); }); - xit('with new mentor', async () => { + it('with new mentor', async () => { const mentee = accountFixtures.mentees[0]; await APISignUpMentee(mentee); const mentor = accountFixtures.mentors[0]; @@ -62,47 +59,4 @@ describe('Chat', () => { await expect(element(by.text(message_from_mentee))).toBeVisible(); await expect(element(by.text(message_from_mentor))).toBeVisible(); }); - - it('if buddy with most recent message deletes account, can receive still messages from other users', async () => { - const mentee = accountFixtures.mentees[0]; - await APISignUpMentee(mentee); - const mentor = accountFixtures.mentors[0]; - await APISignUpMentor(mentor); - - // mentee sends a msg to mentor - const { - sender_id: menteeId, - sender_info: mentee_info, - recipient_id: mentorId, - senderHeaders: menteeHeaders, - } = await APIGetSendInfo(mentee, mentor); - await APISendMessage({ - sender_id: menteeId, - recipient_id: mentorId, - content: 'Hi first', - headers: menteeHeaders, - }); - - await signIn(mentor); - await element(by.id('tabs.chats')).tap(); - await element(by.text(mentee.displayName)).tap(); - await element(by.id('chat.back.button')).tap(); - - // delete mentee account - await APIDeleteAccount(mentee_info.account_id, menteeHeaders) - - // new mentee - const newMentee = accountFixtures.mentees[1]; - await APISignUpMentee(newMentee); - const { - sender_id: newMenteeId, - senderHeaders: newMenteeHeaders, - } = await APIGetSendInfo(newMentee, mentor); - await APISendMessage({ - sender_id: newMenteeId, - recipient_id: mentorId, - content: 'Hi second', - headers: newMenteeHeaders, - }); - }); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3f548fcd..86501eee 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -211,12 +211,14 @@ export async function APIDeleteAccounts() { /** * Makes HTTP API calls to delete user */ -export async function APIDeleteAccount(id: string, headers: Record) { - - await fetch(`${API_URL}/accounts/${id}`, { - method: 'DELETE', - headers, - }); +export async function APIDeleteAccount( + id: string, + headers: Record, +) { + await fetch(`${API_URL}/accounts/${id}`, { + method: 'DELETE', + headers, + }); } /** diff --git a/src/api/messages.ts b/src/api/messages.ts index 4c53b973..968a23a9 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -205,16 +205,41 @@ const sortSentTime = (a: Message, b: Message) => { return a.sentTime < b.sentTime ? -1 : 1; }; -export const getIsOldestFetchedMessageUnread = ( - fetchedBuddyMessages: Record, -) => { - const sorted = Object.keys(fetchedBuddyMessages) - .map(msgId => fetchedBuddyMessages[msgId]) - .sort(sortSentTime); - - return !sorted[sorted.length - 1].isSeen; +export const getParamsForUnreadMessages = ( + messages: MessageMapping, + params: PollingParams, +): Array => { + switch (params.type) { + case 'OlderThan': { + return getOlderThanParamsIfOldestUnread(messages)(params.buddyId); + } + + case 'InitialMessages': { + return params.buddyIds.flatMap( + getOlderThanParamsIfOldestUnread(messages), + ); + } + + default: { + return []; + } + } }; +export const getOlderThanParamsIfOldestUnread = + (messages: MessageMapping) => + (buddyId: string): Array => { + const sorted = Object.keys(messages[buddyId]) + .map(msgId => messages[buddyId][msgId]) + .sort(sortSentTime); + const oldest = sorted.length > 0 ? sorted[sorted.length - 1] : null; + const isOldestUnseen = oldest && !oldest.isSeen; + + return isOldestUnseen + ? [{ type: 'OlderThan', buddyId, messageId: oldest.messageId }] + : []; + }; + export const extractMostRecentId = (messages: MessageMapping) => { const flattenedMessages = Object.keys(messages).reduce< Record diff --git a/src/state/reducers/markSeen.ts b/src/state/reducers/markSeen.ts index bbe04529..4e17d135 100644 --- a/src/state/reducers/markSeen.ts +++ b/src/state/reducers/markSeen.ts @@ -41,6 +41,10 @@ export const reducer: automaton.Reducer = ( type: 'messages/markSeen', payload: { messages }, })), + // { + // type: 'messages/markSeen', + // payload: { messages }, + // }, ); } diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index cb3b7491..97e7ba5b 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -93,32 +93,30 @@ export const reducer: automaton.Reducer = ( const previousMsgId = messageApi.extractMostRecentId(nextMessages); - const isFetchingOlderMessagesAndIsOldestFetchedUnread = - state.currentParams.type === 'OlderThan' - ? messageApi.getIsOldestFetchedMessageUnread( - newMessages[state.currentParams.buddyId], - ) - : false; - - const [first, rest] = messageApi.getNextParams( + // TODO: add some e2e-test + // case 1: oldermessage-fetching (for example initial) + // - mentee sends 20 messages to mentor + // - mentor login + // - mentor waits 5 seconds (the poll interval) + // - mentor scrolls up to the converstaion ( all messages have been loaded ) + // - mentor opens chat + // case 2: marking unread message + // case 3: marking unread only for messages that are seen 100% + const newOlderThanParams = messageApi.getParamsForUnreadMessages( + newMessages, + state.currentParams, + ); + + const [nextCurrent, nextQueue] = messageApi.getNextParams( action.payload, state.pollingQueue, state.currentParams, previousMsgId, ); - const { nextFetch, nextCurrent, nextQueue } = - isFetchingOlderMessagesAndIsOldestFetchedUnread - ? { - nextCurrent: state.currentParams, - nextFetch: state.currentParams, - nextQueue: [first, ...rest], - } - : { nextCurrent: first, nextFetch: first, nextQueue: rest }; - const nextCmd = withToken( flow( - messageApi.fetchMessages(nextFetch), + messageApi.fetchMessages(nextCurrent), T.delay(config.messageFetchDelay), ), actions.make('messages/get/completed'), @@ -129,8 +127,8 @@ export const reducer: automaton.Reducer = ( ...state, messages: RD.success(nextMessages), previousMsgId, - pollingQueue: nextQueue, currentParams: nextCurrent, + pollingQueue: [...nextQueue, ...newOlderThanParams], }, nextCmd, ); @@ -181,7 +179,7 @@ export const reducer: automaton.Reducer = ( return state; } - const [first, ..._rest] = action.payload.messages; + const [first] = action.payload.messages; if (first.type === 'Sent' || first.isSeen === true) { return state; From 9c79b27b21645ac1d2ef6ccf93dc63a60e602bba Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Fri, 10 May 2024 10:38:35 +0300 Subject: [PATCH 06/13] Fetch more older if any messsage is unread - Its safer that way, and also it is fine --- src/api/messages.ts | 29 ++++++++++++++++------------- src/state/reducers/messages.ts | 4 ++++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/api/messages.ts b/src/api/messages.ts index 968a23a9..b87a36f2 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -209,15 +209,14 @@ export const getParamsForUnreadMessages = ( messages: MessageMapping, params: PollingParams, ): Array => { + console.log('getParamsForUnreadMessages'); switch (params.type) { case 'OlderThan': { - return getOlderThanParamsIfOldestUnread(messages)(params.buddyId); + return getOlderThanParamsIfHasUnread(messages)(params.buddyId); } case 'InitialMessages': { - return params.buddyIds.flatMap( - getOlderThanParamsIfOldestUnread(messages), - ); + return params.buddyIds.flatMap(getOlderThanParamsIfHasUnread(messages)); } default: { @@ -226,17 +225,21 @@ export const getParamsForUnreadMessages = ( } }; -export const getOlderThanParamsIfOldestUnread = +export const getOlderThanParamsIfHasUnread = (messages: MessageMapping) => (buddyId: string): Array => { - const sorted = Object.keys(messages[buddyId]) - .map(msgId => messages[buddyId][msgId]) - .sort(sortSentTime); - const oldest = sorted.length > 0 ? sorted[sorted.length - 1] : null; - const isOldestUnseen = oldest && !oldest.isSeen; - - return isOldestUnseen - ? [{ type: 'OlderThan', buddyId, messageId: oldest.messageId }] + console.log('getOlderThanParamsIfHasUnread for buddyId:', buddyId); + const buddyMessages = messages[buddyId] ?? {}; + const sorted = Object.keys(buddyMessages).map( + msgId => buddyMessages[msgId], + ); + + const hasUnread = sorted.some(message => !message.isSeen); + + console.log('hasUnread', hasUnread); + + return hasUnread + ? [{ type: 'OlderThan', buddyId, messageId: sorted[0].messageId }] : []; }; diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index 97e7ba5b..733b0005 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -102,11 +102,15 @@ export const reducer: automaton.Reducer = ( // - mentor opens chat // case 2: marking unread message // case 3: marking unread only for messages that are seen 100% + console.log('got messages', newMessages); const newOlderThanParams = messageApi.getParamsForUnreadMessages( newMessages, state.currentParams, ); + console.log('polled with', state.currentParams); + console.log('new older than params', newOlderThanParams); + const [nextCurrent, nextQueue] = messageApi.getNextParams( action.payload, state.pollingQueue, From 27dc966e4314639ed4b7fc4f1ff7bdc2341020b2 Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Fri, 10 May 2024 10:39:51 +0300 Subject: [PATCH 07/13] Move new older-than params to front of the queue --- src/api/messages.ts | 1 + src/state/reducers/messages.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/messages.ts b/src/api/messages.ts index b87a36f2..74663df1 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -230,6 +230,7 @@ export const getOlderThanParamsIfHasUnread = (buddyId: string): Array => { console.log('getOlderThanParamsIfHasUnread for buddyId:', buddyId); const buddyMessages = messages[buddyId] ?? {}; + const sorted = Object.keys(buddyMessages).map( msgId => buddyMessages[msgId], ); diff --git a/src/state/reducers/messages.ts b/src/state/reducers/messages.ts index 733b0005..826f9dd4 100644 --- a/src/state/reducers/messages.ts +++ b/src/state/reducers/messages.ts @@ -103,6 +103,7 @@ export const reducer: automaton.Reducer = ( // case 2: marking unread message // case 3: marking unread only for messages that are seen 100% console.log('got messages', newMessages); + const newOlderThanParams = messageApi.getParamsForUnreadMessages( newMessages, state.currentParams, @@ -132,7 +133,7 @@ export const reducer: automaton.Reducer = ( messages: RD.success(nextMessages), previousMsgId, currentParams: nextCurrent, - pollingQueue: [...nextQueue, ...newOlderThanParams], + pollingQueue: [...newOlderThanParams, ...nextQueue], }, nextCmd, ); From b506a8311b460592019ebb67fd6c91d253da3975 Mon Sep 17 00:00:00 2001 From: Felix Hallenberg Date: Sat, 11 May 2024 10:17:48 +0300 Subject: [PATCH 08/13] Add e2e for marking unread messages --- e2e/chatTest.spec.ts | 96 +++++++++++++++++++-- e2e/fixtures/accounts.json | 9 +- e2e/helpers.ts | 15 ++++ src/Screens/Main/Chat/MessageList/index.tsx | 2 - src/Screens/Main/MentorCardExpanded.tsx | 1 + src/Screens/components/MentorCard.tsx | 1 + src/api/messages.ts | 4 - src/state/reducers/markSeen.ts | 2 - src/state/reducers/messages.ts | 7 +- 9 files changed, 115 insertions(+), 22 deletions(-) diff --git a/e2e/chatTest.spec.ts b/e2e/chatTest.spec.ts index 098790af..8ec09f92 100644 --- a/e2e/chatTest.spec.ts +++ b/e2e/chatTest.spec.ts @@ -1,25 +1,27 @@ import { by, element, device, expect } from 'detox'; -import { describe, it, beforeEach, beforeAll } from '@jest/globals'; +import { describe, it, beforeEach, expect as jestExpect } from '@jest/globals'; import accountFixtures from './fixtures/accounts.json'; import { APISignUpMentee, APISignUpMentor, + APIGetSendInfo, APIDeleteAccounts, + APISendMessage, waitAndTypeText, signIn, forceLogout, + detoxElementCount, } from './helpers'; describe('Chat', () => { - beforeAll(async () => { - await device.launchApp(); - jest.setTimeout(200000); - }); beforeEach(async () => { await APIDeleteAccounts(); await device.reloadReactNative(); }); + afterEach(async () => { + await forceLogout(); + }); it('with new mentor', async () => { const mentee = accountFixtures.mentees[0]; @@ -32,8 +34,8 @@ describe('Chat', () => { await signIn(mentee); - await element(by.text('Show mentor')).tap(); - await element(by.text('Chat')).tap(); + await element(by.id('components.mentorCard.readMore')).tap(); + await element(by.id('main.mentorCardExpanded.button')).tap(); await waitAndTypeText('main.chat.input.input', message_from_mentee, true); await element(by.id('main.chat.input.button')).tap(); @@ -59,4 +61,84 @@ describe('Chat', () => { await expect(element(by.text(message_from_mentee))).toBeVisible(); await expect(element(by.text(message_from_mentor))).toBeVisible(); }); + + const sendMultiple = async ( + from: string, + to: string, + headers: { Authorization: string }, + content: string, + amount: number, + ) => { + for (let i = 0; i < amount; i++) { + await APISendMessage({ + sender_id: from, + recipient_id: to, + content: `${content} ${i}`, + headers, + }); + } + }; + + it('marks message unseen', async () => { + const mentee = accountFixtures.mentees[0]; + await APISignUpMentee(mentee); + const mentee2 = accountFixtures.mentees[1]; + await APISignUpMentee(mentee2); + const mentor = accountFixtures.mentors[0]; + await APISignUpMentor(mentor); + + const { + sender_id: menteeId, + recipient_id: mentorId, + senderHeaders: menteeHeaders, + } = await APIGetSendInfo(mentee, mentor); + await sendMultiple(menteeId, mentorId, menteeHeaders, 'Hello', 5); + + const { sender_id: mentee2Id, senderHeaders: mentee2Headers } = + await APIGetSendInfo(mentee2, mentor); + await sendMultiple(mentee2Id, mentorId, mentee2Headers, 'Hello', 10); + + await signIn(mentor); + await element(by.id('tabs.chats')).tap(); + + const unseenDotsAmountBefore = await detoxElementCount( + by.id('main.buddyList.button.unseenDot'), + ); + jestExpect(unseenDotsAmountBefore).toBe(2); + + await element(by.text(mentee.displayName)).tap(); + await expect(element(by.text('Hello 0'))).toBeVisible(); + await expect(element(by.text('Hello 4'))).toBeVisible(); + + await element(by.id('chat.back.button')).tap(); + + const unseenDotsAmountAfter = await detoxElementCount( + by.id('main.buddyList.button.unseenDot'), + ); + jestExpect(unseenDotsAmountAfter).toBe(1); + }); + + it('marks message unseen only if fully visible', async () => { + const mentee = accountFixtures.mentees[0]; + await APISignUpMentee(mentee); + const mentor = accountFixtures.mentors[0]; + await APISignUpMentor(mentor); + + const { + sender_id: menteeId, + recipient_id: mentorId, + senderHeaders: menteeHeaders, + } = await APIGetSendInfo(mentee, mentor); + await sendMultiple(menteeId, mentorId, menteeHeaders, 'Hello', 10); + + await signIn(mentor); + await element(by.id('tabs.chats')).tap(); + await element(by.text(mentee.displayName)).tap(); + + await expect(element(by.text('Hello 0'))).not.toBeVisible(); + await expect(element(by.text('Hello 9'))).toBeVisible(); + + await element(by.id('chat.back.button')).tap(); + await expect(element(by.id('main.tabs.unseenDot'))).toBeVisible(); + }); }); diff --git a/e2e/fixtures/accounts.json b/e2e/fixtures/accounts.json index ff88e367..71793986 100644 --- a/e2e/fixtures/accounts.json +++ b/e2e/fixtures/accounts.json @@ -9,10 +9,17 @@ }, { "loginName": "mentee1", - "displayName": "mentee1_nick", + "displayName": "mentee_mummo", "password": "Menteementee!", "email": "mentee1@mentee.mentee", "role": "mentee" + }, + { + "loginName": "mentee2", + "displayName": "mentee_seppo", + "password": "Menteementee!", + "email": "mentee2@mentee.mentee", + "role": "mentee" } ], "mentors": [ diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 86501eee..4f097f3d 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,4 +1,5 @@ import { by, element, waitFor, device } from 'detox'; +import { NativeMatcher } from 'detox/detox'; import { generateToken } from 'node-2fa'; const API_URL = process.env.YLITSE_API_URL || 'http://127.0.0.1:8080'; @@ -521,3 +522,17 @@ export async function APIUpdateMentor(mentorName: string, mentor: any) { body: JSON.stringify(updatedMentor), }); } + +export async function detoxElementCount(matcher: NativeMatcher) { + try { + const attributes = await element(matcher)?.getAttributes(); + + if ('elements' in attributes) { + return attributes.elements.length; + } else { + return 1; + } + } catch (e) { + return 0; + } +} diff --git a/src/Screens/Main/Chat/MessageList/index.tsx b/src/Screens/Main/Chat/MessageList/index.tsx index 332a78d2..a1ad24c8 100644 --- a/src/Screens/Main/Chat/MessageList/index.tsx +++ b/src/Screens/Main/Chat/MessageList/index.tsx @@ -116,8 +116,6 @@ export const MessageList = ({ ) .map(({ item }) => item.value); - console.log('changed', unSeenMessagesOnScreen); - dispatch(markSeen({ messages: unSeenMessagesOnScreen })); }; diff --git a/src/Screens/Main/MentorCardExpanded.tsx b/src/Screens/Main/MentorCardExpanded.tsx index bbed7c78..7d5c27ca 100644 --- a/src/Screens/Main/MentorCardExpanded.tsx +++ b/src/Screens/Main/MentorCardExpanded.tsx @@ -101,6 +101,7 @@ const MentorCardExpanded = ({ navigation, route }: Props) => { style={styles.button} onPress={shouldNavigateBack ? goBack : navigateToChat} messageId="main.mentorCardExpanded.button" + testID="main.mentorCardExpanded.button" disabled={isChatDisabled || isMe} /> diff --git a/src/Screens/components/MentorCard.tsx b/src/Screens/components/MentorCard.tsx index 5d6d96e0..bc04060e 100644 --- a/src/Screens/components/MentorCard.tsx +++ b/src/Screens/components/MentorCard.tsx @@ -52,6 +52,7 @@ const MentorCard: React.FC = ({ onPress, style, mentor }) => {