Skip to content

Commit

Permalink
Show/hide "jump to last message" button on visibility of the last mes…
Browse files Browse the repository at this point in the history
…sage;

Mark all the conversation as read on the "jump to last message" button click;
Reload the conversation on the "jump to last message" click if the latest conversation messages were not load;
  • Loading branch information
Roma Koval committed May 10, 2024
1 parent bb19977 commit caec31b
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 24 deletions.
52 changes: 34 additions & 18 deletions src/script/components/Conversation/Conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ import {UIEvent, useCallback, useState} from 'react';

import {amplify} from 'amplify';
import cx from 'classnames';
import ko from 'knockout';
import {container} from 'tsyringe';

import {useMatchMedia, IconButton, ChevronIcon} from '@wireapp/react-ui-kit';
import {useMatchMedia} from '@wireapp/react-ui-kit';
import {WebAppEvents} from '@wireapp/webapp-events';

import {CallingCell} from 'Components/calling/CallingCell';
import {DropFileArea} from 'Components/DropFileArea';
import {Giphy} from 'Components/Giphy';
import {InputBar} from 'Components/InputBar';
import {LastMessageTracker, MessageVisibility} from 'Components/LastMessageTracker';
import {MessagesList} from 'Components/MessagesList';
import {showDetailViewModal} from 'Components/Modals/DetailViewModal';
import {PrimaryModal} from 'Components/Modals/PrimaryModal';
Expand Down Expand Up @@ -122,6 +124,8 @@ export const Conversation = ({

const [isMsgElementsFocusable, setMsgElementsFocusable] = useState(true);

const messageVisibility: ko.Observable<MessageVisibility> = ko.observable();

// To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly
const smBreakpoint = useMatchMedia('max-width: 640px');

Expand Down Expand Up @@ -404,9 +408,14 @@ export const Conversation = ({
}
};

const visibleInViewportCallback = useCallback((isVisible: boolean, messageEntity: Message) => {
messageVisibility({isVisible, message: messageEntity});
}, []);

const getInViewportCallback = useCallback(
(conversationEntity: ConversationEntity, messageEntity: Message) => {
const messageTimestamp = messageEntity.timestamp();

const callbacks: Function[] = [];

if (!messageEntity.isEphemeral()) {
Expand Down Expand Up @@ -469,9 +478,15 @@ export const Conversation = ({
);

const onGoToLastMessage = () => {
activeConversation?.release();
amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation);
// content.showConversation(activeConversation, {exposeMessage: activeConversation?.lastDeliveredMessage()});
activeConversation?.setTimestamp(
activeConversation?.last_server_timestamp(),
ConversationEntity.TIMESTAMP_TYPE.LAST_READ,
);
if (!activeConversation?.hasLastReceivedMessageLoaded()) {
activeConversation?.release();
amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {});
}
// scroll
};

return (
Expand Down Expand Up @@ -541,26 +556,27 @@ export const Conversation = ({
onClickMessage={handleClickOnMessage}
onLoading={loading => setIsConversationLoaded(!loading)}
getVisibleCallback={getInViewportCallback}
getVisibleEachTimeCallback={(isVisible, _conversationEntity, messageEntity) =>
visibleInViewportCallback(isVisible, messageEntity)
}
isLastReceivedMessage={isLastReceivedMessage}
isMsgElementsFocusable={isMsgElementsFocusable}
setMsgElementsFocusable={setMsgElementsFocusable}
isRightSidebarOpen={isRightSidebarOpen}
/>

{(!activeConversation.hasLastReceivedMessageLoaded() || true) && (
<IconButton
onClick={onGoToLastMessage}
css={{
position: 'absolute',
bottom: '90px',
right: '50px',
height: '40px',
borderRadius: '100%',
}}
>
<ChevronIcon css={{rotate: '90deg', height: 16, width: 16, path: {fill: '#0667C8'}}} />
</IconButton>
)}
<LastMessageTracker
onGoToLastMessage={onGoToLastMessage}
conversation={activeConversation}
messageVisibility={messageVisibility}
css={{
position: 'absolute',
bottom: '90px',
right: '50px',
height: '40px',
borderRadius: '100%',
}}
/>

{isConversationLoaded &&
(isReadOnlyConversation ? (
Expand Down
79 changes: 79 additions & 0 deletions src/script/components/LastMessageTracker/LastMessageTracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {HTMLProps, useState, FC, useEffect} from 'react';

import ko from 'knockout';

import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit';

import {Conversation as ConversationEntity} from '../../entity/Conversation';
import {Message} from '../../entity/message/Message';

export interface MessageVisibility {
message: Message;
isVisible: boolean;
}

export interface LastMessageTrackerProps extends HTMLProps<HTMLElement> {
onGoToLastMessage: () => void;
messageVisibility: ko.Observable<MessageVisibility>;
conversation: ConversationEntity;
}

// conversation.last_event_timestamp doesn't contain system messages
const lastMessageId = (conversation: ConversationEntity): string => {
if (!conversation.hasLastReceivedMessageLoaded() || (conversation.messages()?.length || 0) === 0) {
return '';
}
return conversation.messages()[conversation.messages().length - 1].id;
};

export const LastMessageTracker: FC<LastMessageTrackerProps> = ({
onGoToLastMessage,
messageVisibility,
conversation,
...rest
}: LastMessageTrackerProps) => {
const [lastMessageShown, setLastMessageShown] = useState<boolean>(false);

useEffect(() => {
const subscription = messageVisibility.subscribe(({message, isVisible}: MessageVisibility) => {
if (!conversation.hasLastReceivedMessageLoaded()) {
setLastMessageShown(false);
} else if (message.id === lastMessageId(conversation)) {
setLastMessageShown(isVisible);
}
});

return () => {
subscription.dispose();
};
}, []);

if (lastMessageShown) {
return null;
}

return (
<IconButton onClick={onGoToLastMessage} {...rest}>

Check failure on line 75 in src/script/components/LastMessageTracker/LastMessageTracker.tsx

View workflow job for this annotation

GitHub Actions / test

Type '{ children: Element; accept?: string; acceptCharset?: string; action?: string; allowFullScreen?: boolean; allowTransparency?: boolean; alt?: string; as?: string; async?: boolean; autoComplete?: string; ... 356 more ...; key?: Key; }' is not assignable to type 'IconButtonProps<HTMLButtonElement>'.
<ChevronIcon css={{rotate: '90deg', height: 16, width: 16, path: {fill: '#0667C8'}}} />
</IconButton>
);
};
20 changes: 20 additions & 0 deletions src/script/components/LastMessageTracker/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

export * from './LastMessageTracker';
10 changes: 9 additions & 1 deletion src/script/components/MessagesList/Message/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface MessageParams extends MessageActions {
};
messageRepository: MessageRepository;
onVisible?: () => void;
onVisibilityChange?: (isVisible: boolean) => void;
selfId: QualifiedId;
shouldShowInvitePeople: boolean;
teamState?: TeamState;
Expand All @@ -88,6 +89,7 @@ export const Message: React.FC<MessageParams & {scrollTo?: ScrollToElement}> = p
isHighlighted,
hideHeader,
onVisible,
onVisibilityChange,
scrollTo,
isFocused,
handleFocus,
Expand Down Expand Up @@ -154,7 +156,13 @@ export const Message: React.FC<MessageParams & {scrollTo?: ScrollToElement}> = p
);

const wrappedContent = onVisible ? (
<InViewport requireFullyInView allowBiggerThanViewport checkOverlay onVisible={onVisible}>
<InViewport
requireFullyInView
allowBiggerThanViewport
checkOverlay
onVisible={onVisible}
onVisibilityChange={onVisibilityChange}
>
{content}
</InViewport>
) : (
Expand Down
13 changes: 11 additions & 2 deletions src/script/components/MessagesList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ interface MessagesListParams {
cancelConnectionRequest: (message: MemberMessage) => void;
conversation: Conversation;
conversationRepository: ConversationRepository;
getVisibleCallback: (conversationEntity: Conversation, messageEntity: MessageEntity) => (() => void) | undefined;
getVisibleCallback?: (conversationEntity: Conversation, messageEntity: MessageEntity) => () => void;
getVisibleEachTimeCallback?: (
isVisible: boolean,
conversationEntity: Conversation,
messageEntity: MessageEntity,
) => void;
initialMessage?: MessageEntity;
invitePeople: (convesation: Conversation) => void;
messageActions: {
Expand Down Expand Up @@ -80,6 +85,7 @@ export const MessagesList: FC<MessagesListParams> = ({
conversationRepository,
messageRepository,
getVisibleCallback,
getVisibleEachTimeCallback,
onClickMessage,
showUserDetails,
showMessageDetails,
Expand Down Expand Up @@ -264,7 +270,9 @@ export const MessagesList: FC<MessagesListParams> = ({
return messages.map(message => {
const isLastDeliveredMessage = lastDeliveredMessage?.id === message.id;

const visibleCallback = getVisibleCallback(conversation, message);
const visibleCallback = getVisibleCallback?.(conversation, message);
const visibleEachTimeCallback = (isVisible: boolean) =>
getVisibleEachTimeCallback?.(isVisible, conversation, message);

const key = `${message.id || 'message'}-${message.timestamp()}`;

Expand All @@ -275,6 +283,7 @@ export const MessagesList: FC<MessagesListParams> = ({
<Message
key={key}
onVisible={visibleCallback}
onVisibilityChange={visibleEachTimeCallback}
message={message}
hideHeader={message.timestamp() !== firstMessageTimestamp}
messageActions={messageActions}
Expand Down
23 changes: 21 additions & 2 deletions src/script/components/utils/InViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {viewportObserver} from 'Util/DOM/viewportObserver';

interface InViewportParams {
onVisible: () => void;
onVisibilityChange?: (isVisible: boolean) => void;
requireFullyInView?: boolean;
allowBiggerThanViewport?: boolean;
/** Will check if the element is overlayed by something else. Can be used to be sure the user could actually see the element. Should not be used to do lazy loading as the overlayObserver has quite a long debounce time */
Expand All @@ -33,6 +34,7 @@ interface InViewportParams {
const InViewport: React.FC<InViewportParams & React.HTMLProps<HTMLDivElement>> = ({
children,
onVisible,
onVisibilityChange,
requireFullyInView = false,
checkOverlay = false,
allowBiggerThanViewport = false,
Expand All @@ -48,6 +50,10 @@ const InViewport: React.FC<InViewportParams & React.HTMLProps<HTMLDivElement>> =

let inViewport = false;
let visible = !checkOverlay;

let onVisibleTriggered = false;
let onVisibilityChangeLastValue = false;

const releaseTrackers = () => {
if (checkOverlay) {
overlayedObserver.removeElement(element);
Expand All @@ -57,8 +63,21 @@ const InViewport: React.FC<InViewportParams & React.HTMLProps<HTMLDivElement>> =

const triggerCallbackIfVisible = () => {
if (inViewport && visible) {
onVisible();
releaseTrackers();
if (!onVisibleTriggered) {
onVisible();
onVisibleTriggered = true;
}
if (onVisibilityChange) {
if (!onVisibilityChangeLastValue) {
onVisibilityChange(true);
onVisibilityChangeLastValue = true;
}
} else {
releaseTrackers();
}
} else if (onVisibilityChange && onVisibilityChangeLastValue) {
onVisibilityChange?.(false);
onVisibilityChangeLastValue = false;
}
};

Expand Down
1 change: 1 addition & 0 deletions src/script/entity/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export class Conversation {
return message_a.timestamp() - message_b.timestamp();
}),
);

this.lastDeliveredMessage = ko.pureComputed(() => this.getLastDeliveredMessage());

this.incomingMessages = ko.observableArray();
Expand Down
2 changes: 1 addition & 1 deletion src/script/view_model/ContentViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export class ContentViewModel {
exposeMessage: exposeMessageEntity,
openFirstSelfMention = false,
openNotificationSettings = false,
} = options;
} = options || {};

if (!conversation) {
return this.handleMissingConversation();
Expand Down

0 comments on commit caec31b

Please sign in to comment.