Implement sending read receipt in new pagination

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2021-12-07 21:04:07 +05:30
parent 50db137dea
commit c1e3645d57
9 changed files with 244 additions and 132 deletions

View file

@ -4,5 +4,7 @@ import { useState } from 'react';
export function useForceUpdate() { export function useForceUpdate() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
return [data, () => setData({})]; return [data, function forceUpdateHook() {
setData({});
}];
} }

View file

@ -11,6 +11,7 @@ import * as roomActions from '../../../client/action/room';
import ContextMenu, { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import ContextMenu, { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg'; import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg'; import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg'; import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
@ -148,6 +149,14 @@ function RoomOptions() {
}; };
}, []); }, []);
const handleMarkAsRead = () => {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
if (!room) return;
const events = room.getLiveTimeline().getEvents();
mx.sendReadReceipt(events[events.length - 1]);
};
const handleInviteClick = () => openInviteUser(roomId); const handleInviteClick = () => openInviteUser(roomId);
const handleLeaveClick = (toggleMenu) => { const handleLeaveClick = (toggleMenu) => {
if (confirm('Are you really want to leave this room?')) { if (confirm('Are you really want to leave this room?')) {
@ -169,6 +178,14 @@ function RoomOptions() {
content={(toggleMenu) => ( content={(toggleMenu) => (
<> <>
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader> <MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
<MenuItem
iconSrc={TickMarkIC}
onClick={() => {
handleMarkAsRead(); toggleMenu();
}}
>
Mark as read
</MenuItem>
<MenuItem <MenuItem
iconSrc={AddUserIC} iconSrc={AddUserIC}
onClick={() => { onClick={() => {

View file

@ -24,12 +24,10 @@ function RoomView({ roomTimeline, eventId }) {
<RoomViewContent <RoomViewContent
eventId={eventId} eventId={eventId}
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
viewEvent={viewEvent}
/> />
<RoomViewFloating <RoomViewFloating
roomId={roomId} roomId={roomId}
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
viewEvent={viewEvent}
/> />
</div> </div>
<div className="room-view__sticky"> <div className="room-view__sticky">

View file

@ -122,15 +122,14 @@ function ViewCmd() {
function FollowingMembers({ roomId, roomTimeline, viewEvent }) { function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
const [followingMembers, setFollowingMembers] = useState([]); const [followingMembers, setFollowingMembers] = useState([]);
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const myUserId = mx.getUserId();
const handleOnMessageSent = () => setFollowingMembers([]); const handleOnMessageSent = () => setFollowingMembers([]);
const updateFollowingMembers = () => {
const myUserId = mx.getUserId();
setFollowingMembers(roomTimeline.getLiveReaders().filter((userId) => userId !== myUserId));
};
useEffect(() => { useEffect(() => {
const updateFollowingMembers = () => {
setFollowingMembers(roomTimeline.getLiveReaders());
};
updateFollowingMembers(); updateFollowingMembers();
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers); roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
viewEvent.on('message_sent', handleOnMessageSent); viewEvent.on('message_sent', handleOnMessageSent);
@ -140,10 +139,11 @@ function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
}; };
}, [roomTimeline]); }, [roomTimeline]);
return followingMembers.length !== 0 && ( const filteredM = followingMembers.filter((userId) => userId !== myUserId);
return filteredM.length !== 0 && (
<TimelineChange <TimelineChange
variant="follow" variant="follow"
content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')} content={getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
time="" time=""
onClick={() => openReadReceipts(roomId, followingMembers)} onClick={() => openReadReceipts(roomId, followingMembers)}
/> />

View file

@ -90,7 +90,7 @@ function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
if (mEvent.getType() === 'm.room.member') { if (mEvent.getType() === 'm.room.member') {
const timelineChange = parseTimelineChange(mEvent); const timelineChange = parseTimelineChange(mEvent);
if (timelineChange === null) return false; if (timelineChange === null) return <div key={mEvent.getId()} />;
return ( return (
<TimelineChange <TimelineChange
key={mEvent.getId()} key={mEvent.getId()}
@ -147,7 +147,7 @@ class TimelineScroll extends EventEmitter {
let scrollTop = 0; let scrollTop = 0;
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop; const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
if (!ot) scrollTop = this.top; if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
else scrollTop = ot - this.diff; else scrollTop = ot - this.diff;
this._scrollTo(scrollInfo, scrollTop); this._scrollTo(scrollInfo, scrollTop);
@ -255,7 +255,7 @@ class TimelineScroll extends EventEmitter {
} }
let timelineScroll = null; let timelineScroll = null;
let focusEventIndex = null; let jumpToItemIndex = -1;
const throttle = new Throttle(); const throttle = new Throttle();
const limit = { const limit = {
from: 0, from: 0,
@ -282,24 +282,9 @@ const limit = {
}, },
}; };
function useTimeline(roomTimeline, eventId) { function useTimeline(roomTimeline, eventId, readEventStore) {
const [timelineInfo, setTimelineInfo] = useState(null); const [timelineInfo, setTimelineInfo] = useState(null);
// TODO:
// open specific event.
// 1. readUpTo event is in specific timeline
// 2. readUpTo event isn't in specific timeline
// 3. readUpTo event is specific event
// open live timeline.
// 1. readUpTo event is in live timeline
// 2. readUpTo event isn't in live timeline
const initTimeline = (eId) => {
limit.setFrom(roomTimeline.timeline.length - limit.getMaxEvents());
setTimelineInfo({
focusEventId: eId,
});
};
const setEventTimeline = async (eId) => { const setEventTimeline = async (eId) => {
if (typeof eId === 'string') { if (typeof eId === 'string') {
const isLoaded = await roomTimeline.loadEventTimeline(eId); const isLoaded = await roomTimeline.loadEventTimeline(eId);
@ -311,6 +296,35 @@ function useTimeline(roomTimeline, eventId) {
}; };
useEffect(() => { useEffect(() => {
const initTimeline = (eId) => {
// NOTICE: eId can be id of readUpto, reply or specific event.
// readUpTo: when user click jump to unread message button.
// reply: when user click reply from timeline.
// specific event when user open a link of event. behave same as ^^^^
const readUpToId = roomTimeline.getReadUpToEventId();
let focusEventIndex = -1;
const isSpecificEvent = eId && eId !== readUpToId;
if (isSpecificEvent) {
focusEventIndex = roomTimeline.getEventIndex(eId);
} else if (!readEventStore.getItem()) {
// either opening live timeline or jump to unread.
focusEventIndex = roomTimeline.getUnreadEventIndex(readUpToId);
if (roomTimeline.hasEventInTimeline(readUpToId)) {
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
}
} else {
focusEventIndex = roomTimeline.getUnreadEventIndex(readEventStore.getItem().getId());
}
if (focusEventIndex > -1) {
limit.setFrom(focusEventIndex - Math.round(limit.getMaxEvents() / 2));
} else {
limit.setFrom(roomTimeline.timeline.length - limit.getMaxEvents());
}
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
};
roomTimeline.on(cons.events.roomTimeline.READY, initTimeline); roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
setEventTimeline(eventId); setEventTimeline(eventId);
return () => { return () => {
@ -323,12 +337,16 @@ function useTimeline(roomTimeline, eventId) {
return timelineInfo; return timelineInfo;
} }
function usePaginate(roomTimeline, forceUpdateLimit) { function usePaginate(roomTimeline, readEventStore, forceUpdateLimit) {
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
useEffect(() => { useEffect(() => {
const handleOnPagination = (backwards, loaded, canLoadMore) => { const handleOnPagination = (backwards, loaded, canLoadMore) => {
if (loaded === 0) return; if (loaded === 0) return;
if (!readEventStore.getItem()) {
const readUpToId = roomTimeline.getReadUpToEventId();
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
}
limit.setFrom(limit.calcNextFrom(backwards, roomTimeline.timeline.length)); limit.setFrom(limit.calcNextFrom(backwards, roomTimeline.timeline.length));
setInfo({ setInfo({
backwards, backwards,
@ -372,96 +390,147 @@ function usePaginate(roomTimeline, forceUpdateLimit) {
return [info, autoPaginate]; return [info, autoPaginate];
} }
function useHandleScroll(roomTimeline, autoPaginate, viewEvent) { function useHandleScroll(roomTimeline, autoPaginate, readEventStore, forceUpdateLimit) {
return useCallback(() => { const handleScroll = useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
// emit event to toggle scrollToBottom button visibility // emit event to toggle scrollToBottom button visibility
const isAtBottom = ( const isAtBottom = (
timelineScroll.bottom < 16 timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
&& !roomTimeline.canPaginateForward() && limit.getEndIndex() >= roomTimeline.timeline.length
&& limit.getEndIndex() === roomTimeline.length
); );
viewEvent.emit('at-bottom', isAtBottom); roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
if (isAtBottom && readEventStore.getItem()) {
requestAnimationFrame(() => roomTimeline.markAllAsRead());
}
}); });
autoPaginate(); autoPaginate();
}, [roomTimeline]); }, [roomTimeline]);
const handleScrollToLive = useCallback(() => {
if (readEventStore.getItem()) {
requestAnimationFrame(() => roomTimeline.markAllAsRead());
}
if (roomTimeline.isServingLiveTimeline()) {
limit.setFrom(roomTimeline.timeline.length - limit.getMaxEvents());
timelineScroll.scrollToBottom();
forceUpdateLimit();
return;
}
roomTimeline.loadLiveTimeline();
}, [roomTimeline]);
return [handleScroll, handleScrollToLive];
} }
function useEventArrive(roomTimeline) { function useEventArrive(roomTimeline, readEventStore) {
const myUserId = initMatrix.matrixClient.getUserId();
const [newEvent, setEvent] = useState(null); const [newEvent, setEvent] = useState(null);
useEffect(() => { useEffect(() => {
const sendReadReceipt = (event) => {
if (event.isSending()) return;
if (myUserId === event.getSender()) {
roomTimeline.markAllAsRead();
return;
}
const readUpToEvent = readEventStore.getItem();
const readUpToId = roomTimeline.getReadUpToEventId();
// if user doesn't have focus on app don't mark messages as read.
if (document.visibilityState === 'hidden' || timelineScroll.bottom >= 16) {
if (readUpToEvent === readUpToId) return;
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
return;
}
if (readUpToEvent?.getId() !== readUpToId) {
roomTimeline.markAllAsRead();
}
};
const handleEvent = (event) => { const handleEvent = (event) => {
const tLength = roomTimeline.timeline.length; const tLength = roomTimeline.timeline.length;
if (roomTimeline.isServingLiveTimeline() && tLength - 1 === limit.getEndIndex()) { if (roomTimeline.isServingLiveTimeline()
&& limit.getEndIndex() >= tLength - 1
&& timelineScroll.bottom < SCROLL_TRIGGER_POS) {
limit.setFrom(tLength - limit.getMaxEvents()); limit.setFrom(tLength - limit.getMaxEvents());
sendReadReceipt(event);
setEvent(event);
} }
setEvent(event);
}; };
const handleEventRedact = (event) => setEvent(event);
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent); roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
return () => { return () => {
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent); roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
}; };
}, [roomTimeline]); }, [roomTimeline]);
useEffect(() => { useEffect(() => {
if (!roomTimeline.initialized) return; if (!roomTimeline.initialized) return;
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) { if (timelineScroll.bottom < 16
&& !roomTimeline.canPaginateForward()
&& document.visibilityState === 'visible') {
timelineScroll.scrollToBottom(); timelineScroll.scrollToBottom();
} }
}, [newEvent, roomTimeline]); }, [newEvent, roomTimeline]);
} }
function RoomViewContent({ function RoomViewContent({ eventId, roomTimeline }) {
eventId, roomTimeline, viewEvent,
}) {
const timelineSVRef = useRef(null); const timelineSVRef = useRef(null);
const readEventStore = useStore(roomTimeline); const readEventStore = useStore(roomTimeline);
const timelineInfo = useTimeline(roomTimeline, eventId); const timelineInfo = useTimeline(roomTimeline, eventId, readEventStore);
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate(); const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
const [paginateInfo, autoPaginate] = usePaginate(roomTimeline, forceUpdateLimit); const [paginateInfo, autoPaginate] = usePaginate(roomTimeline, readEventStore, forceUpdateLimit);
const handleScroll = useHandleScroll(roomTimeline, autoPaginate, viewEvent); const [handleScroll, handleScrollToLive] = useHandleScroll(
useEventArrive(roomTimeline); roomTimeline, autoPaginate, readEventStore, forceUpdateLimit,
);
useEventArrive(roomTimeline, readEventStore);
const { timeline } = roomTimeline; const { timeline } = roomTimeline;
const handleScrollToLive = useCallback(() => {
if (roomTimeline.isServingLiveTimeline()) {
timelineScroll.scrollToBottom();
return;
}
roomTimeline.loadLiveTimeline();
}, [roomTimeline]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!roomTimeline.initialized) { if (!roomTimeline.initialized) {
timelineScroll = new TimelineScroll(timelineSVRef.current); timelineScroll = new TimelineScroll(timelineSVRef.current);
} }
}); });
// when active timeline changes
useEffect(() => { useEffect(() => {
if (!roomTimeline.initialized) return undefined; if (!roomTimeline.initialized) return undefined;
if (timeline.length > 0) { if (timeline.length > 0) {
if (focusEventIndex === null) timelineScroll.scrollToBottom(); if (jumpToItemIndex === -1) {
else timelineScroll.scrollToIndex(focusEventIndex, 80); timelineScroll.scrollToBottom();
focusEventIndex = null; } else {
timelineScroll.scrollToIndex(jumpToItemIndex, 80);
}
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
if (readEventStore.getItem()?.getId() === roomTimeline.getReadUpToEventId()) {
requestAnimationFrame(() => roomTimeline.markAllAsRead());
}
}
jumpToItemIndex = -1;
} }
autoPaginate(); autoPaginate();
timelineScroll.on('scroll', handleScroll); timelineScroll.on('scroll', handleScroll);
viewEvent.on('scroll-to-live', handleScrollToLive); roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
return () => { return () => {
if (timelineSVRef.current === null) return; if (timelineSVRef.current === null) return;
timelineScroll.removeListener('scroll', handleScroll); timelineScroll.removeListener('scroll', handleScroll);
viewEvent.removeListener('scroll-to-live', handleScrollToLive); roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
}; };
}, [timelineInfo]); }, [timelineInfo]);
// when paginating from server
useEffect(() => { useEffect(() => {
if (!roomTimeline.initialized) return; if (!roomTimeline.initialized) return;
timelineScroll.tryRestoringScroll(); timelineScroll.tryRestoringScroll();
autoPaginate(); autoPaginate();
}, [paginateInfo]); }, [paginateInfo]);
// when paginating locally
useEffect(() => { useEffect(() => {
if (!roomTimeline.initialized) return; if (!roomTimeline.initialized) return;
timelineScroll.tryRestoringScroll(); timelineScroll.tryRestoringScroll();
@ -473,29 +542,16 @@ function RoomViewContent({
throttle._(() => timelineScroll?.calcScroll(), 400)(target); throttle._(() => timelineScroll?.calcScroll(), 400)(target);
}; };
const getReadEvent = () => {
const readEventId = roomTimeline.getReadUpToEventId();
if (readEventStore.getItem()?.getId() === readEventId) {
return readEventStore.getItem();
}
if (roomTimeline.hasEventInActiveTimeline(readEventId)) {
return readEventStore.setItem(
roomTimeline.findEventByIdInTimelineSet(readEventId),
);
}
return readEventStore.setItem(null);
};
const renderTimeline = () => { const renderTimeline = () => {
const tl = []; const tl = [];
const readEvent = getReadEvent(); let itemCountIndex = 0;
let extraItemCount = 0; jumpToItemIndex = -1;
focusEventIndex = null; const readEvent = readEventStore.getItem();
if (roomTimeline.canPaginateBackward() || limit.from > 0) { if (roomTimeline.canPaginateBackward() || limit.from > 0) {
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT)); tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
extraItemCount += PLACEHOLDER_COUNT; itemCountIndex += PLACEHOLDER_COUNT;
} }
for (let i = limit.from; i < limit.getEndIndex(); i += 1) { for (let i = limit.from; i < limit.getEndIndex(); i += 1) {
if (i >= timeline.length) break; if (i >= timeline.length) break;
@ -505,30 +561,35 @@ function RoomViewContent({
if (i === 0 && !roomTimeline.canPaginateBackward()) { if (i === 0 && !roomTimeline.canPaginateBackward()) {
if (mEvent.getType() === 'm.room.create') { if (mEvent.getType() === 'm.room.create') {
tl.push(genRoomIntro(mEvent, roomTimeline)); tl.push(genRoomIntro(mEvent, roomTimeline));
itemCountIndex += 1;
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
continue; continue;
} else { } else {
tl.push(genRoomIntro(undefined, roomTimeline)); tl.push(genRoomIntro(undefined, roomTimeline));
extraItemCount += 1; itemCountIndex += 1;
} }
} }
const unreadDivider = (readEvent const unreadDivider = (readEvent
&& prevMEvent?.getTs() <= readEvent.getTs() && prevMEvent?.getTs() <= readEvent.getTs()
&& readEvent.getTs() < mEvent.getTs()); && readEvent.getTs() < mEvent.getTs());
if (unreadDivider) { if (unreadDivider) {
tl.push(<Divider key={`new-${readEvent.getId()}`} variant="positive" text="Unread messages" />); tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
if (focusEventIndex === null) focusEventIndex = i + extraItemCount; itemCountIndex += 1;
if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
} }
const dayDivider = prevMEvent && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate()); const dayDivider = prevMEvent && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate());
if (dayDivider) { if (dayDivider) {
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />); tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
extraItemCount += 1; itemCountIndex += 1;
} }
const focusId = timelineInfo.focusEventId; const focusId = timelineInfo.focusEventId;
const isFocus = focusId === mEvent.getId() && focusId !== readEvent?.getId(); const isFocus = focusId === mEvent.getId();
if (isFocus) focusEventIndex = i + extraItemCount; if (isFocus) jumpToItemIndex = itemCountIndex;
tl.push(renderEvent(roomTimeline, mEvent, prevMEvent, isFocus)); tl.push(renderEvent(roomTimeline, mEvent, prevMEvent, isFocus));
itemCountIndex += 1;
} }
if (roomTimeline.canPaginateForward() || limit.getEndIndex() < timeline.length) { if (roomTimeline.canPaginateForward() || limit.getEndIndex() < timeline.length) {
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT)); tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
@ -554,7 +615,6 @@ RoomViewContent.defaultProps = {
RoomViewContent.propTypes = { RoomViewContent.propTypes = {
eventId: PropTypes.string, eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
}; };
export default RoomViewContent; export default RoomViewContent;

View file

@ -11,7 +11,7 @@ import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from './common'; import { getUsersActionJsx } from './common';
@ -20,28 +20,25 @@ function useJumpToEvent(roomTimeline) {
const jumpToEvent = () => { const jumpToEvent = () => {
roomTimeline.loadEventTimeline(eventId); roomTimeline.loadEventTimeline(eventId);
setEventId(null);
}; };
const cancelJumpToEvent = () => { const cancelJumpToEvent = (mEvent) => {
setEventId(null); setEventId(null);
roomTimeline.markAsRead(); if (!mEvent) roomTimeline.markAllAsRead();
}; };
// TODO: if user reaches the unread messages with other ways
// like by paginating, or loading timeline for that event by other ways ex: clicking on reply.
// then setEventId(null);
useEffect(() => { useEffect(() => {
const readEventId = roomTimeline.getReadUpToEventId(); const readEventId = roomTimeline.getReadUpToEventId();
// we only show "Jump to unread" btn only if the event is not in live timeline. // we only show "Jump to unread" btn only if the event is not in timeline.
// if event is in live timeline // if event is in timeline
// we will automatically open the timeline from that event // we will automatically open the timeline from that event position
if (!roomTimeline.hasEventInLiveTimeline(readEventId)) { if (!readEventId.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
setEventId(readEventId); setEventId(readEventId);
} }
roomTimeline.on(cons.events.roomTimeline.MARKED_AS_READ, cancelJumpToEvent);
return () => { return () => {
roomTimeline.removeListener(cons.events.roomTimeline.MARKED_AS_READ, cancelJumpToEvent);
setEventId(null); setEventId(null);
}; };
}, [roomTimeline]); }, [roomTimeline]);
@ -69,28 +66,28 @@ function useTypingMembers(roomTimeline) {
return [typingMembers]; return [typingMembers];
} }
function useScrollToBottom(roomId, viewEvent) { function useScrollToBottom(roomTimeline) {
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom); const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
useEffect(() => { useEffect(() => {
setIsAtBottom(true); setIsAtBottom(true);
viewEvent.on('at-bottom', handleAtBottom); roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
return () => viewEvent.removeListener('at-bottom', handleAtBottom); return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
}, [roomId]); }, [roomTimeline]);
return [isAtBottom, setIsAtBottom]; return [isAtBottom, setIsAtBottom];
} }
function RoomViewFloating({ function RoomViewFloating({
roomId, roomTimeline, viewEvent, roomId, roomTimeline,
}) { }) {
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline, viewEvent); const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
const [typingMembers] = useTypingMembers(roomTimeline); const [typingMembers] = useTypingMembers(roomTimeline);
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomId, viewEvent); const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
const handleScrollToBottom = () => { const handleScrollToBottom = () => {
viewEvent.emit('scroll-to-live'); roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
setIsAtBottom(true); setIsAtBottom(true);
}; };
@ -104,9 +101,9 @@ function RoomViewFloating({
onClick={cancelJumpToEvent} onClick={cancelJumpToEvent}
variant="primary" variant="primary"
size="extra-small" size="extra-small"
src={CrossIC} src={TickMarkIC}
tooltipPlacement="bottom" tooltipPlacement="bottom"
tooltip="Cancel" tooltip="Mark as read"
/> />
</div> </div>
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}> <div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
@ -126,7 +123,6 @@ function RoomViewFloating({
RoomViewFloating.propTypes = { RoomViewFloating.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
}; };
export default RoomViewFloating; export default RoomViewFloating;

View file

@ -1,11 +1,21 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import cons from './cons'; import cons from './cons';
function isNotifEvent(mEvent) {
const eType = mEvent.getType();
if (!cons.supportEventTypes.includes(eType)) return false;
if (eType === 'm.room.member') return false;
if (mEvent.isRedacted()) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
return true;
}
class Notifications extends EventEmitter { class Notifications extends EventEmitter {
constructor(roomList) { constructor(roomList) {
super(); super();
this.supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
this.matrixClient = roomList.matrixClient; this.matrixClient = roomList.matrixClient;
this.roomList = roomList; this.roomList = roomList;
@ -36,21 +46,14 @@ class Notifications extends EventEmitter {
const readUpToId = room.getEventReadUpTo(userId); const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents(); const liveEvents = room.getLiveTimeline().getEvents();
if (liveEvents.length if (liveEvents[liveEvents.length - 1]?.getSender() === userId) {
&& liveEvents[liveEvents.length - 1].sender
&& liveEvents[liveEvents.length - 1].sender.userId === userId
&& liveEvents[liveEvents.length - 1].getType() !== 'm.room.member') {
return false; return false;
} }
for (let i = liveEvents.length - 1; i >= 0; i -= 1) { for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i]; const event = liveEvents[i];
if (event.getId() === readUpToId) return false; if (event.getId() === readUpToId) return false;
if (isNotifEvent(event)) return true;
if (this.supportEvents.includes(event.getType())) {
return true;
}
} }
return true; return true;
} }
@ -150,7 +153,7 @@ class Notifications extends EventEmitter {
_listenEvents() { _listenEvents() {
this.matrixClient.on('Room.timeline', (mEvent, room) => { this.matrixClient.on('Room.timeline', (mEvent, room) => {
if (!this.supportEvents.includes(mEvent.getType())) return; if (!isNotifEvent(mEvent)) return;
const liveEvents = room.getLiveTimeline().getEvents(); const liveEvents = room.getLiveTimeline().getEvents();
const lastTimelineEvent = liveEvents[liveEvents.length - 1]; const lastTimelineEvent = liveEvents[liveEvents.length - 1];

View file

@ -48,6 +48,15 @@ function iterateLinkedTimelines(timeline, backwards, callback) {
} }
} }
function isTimelineLinked(tm1, tm2) {
let tm = getFirstLinkedTimeline(tm1);
while (tm) {
if (tm === tm2) return true;
tm = tm.nextTimeline;
}
return false;
}
class RoomTimeline extends EventEmitter { class RoomTimeline extends EventEmitter {
constructor(roomId) { constructor(roomId) {
super(); super();
@ -93,8 +102,8 @@ class RoomTimeline extends EventEmitter {
this.timeline = []; this.timeline = [];
// TODO: don't clear these timeline cause there data can be used in other timeline // TODO: don't clear these timeline cause there data can be used in other timeline
// this.reactionTimeline.clear(); this.reactionTimeline.clear();
// this.editedTimeline.clear(); this.editedTimeline.clear();
} }
addToTimeline(mEvent) { addToTimeline(mEvent) {
@ -197,22 +206,29 @@ class RoomTimeline extends EventEmitter {
return Promise.allSettled(decryptionPromises); return Promise.allSettled(decryptionPromises);
} }
markAsRead() { markAllAsRead() {
const readEventId = this.getReadUpToEventId(); const readEventId = this.getReadUpToEventId();
if (this.timeline.length === 0) return; if (this.timeline.length === 0) return;
const latestEvent = this.timeline[this.timeline.length - 1]; const latestEvent = this.timeline[this.timeline.length - 1];
if (readEventId === latestEvent.getId()) return; if (readEventId === latestEvent.getId()) return;
this.matrixClient.sendReadReceipt(latestEvent); this.matrixClient.sendReadReceipt(latestEvent);
this.emit(cons.events.roomTimeline.MARKED_AS_READ, latestEvent);
} }
hasEventInLiveTimeline(eventId) { markAsRead(eventId) {
const timelineSet = this.getUnfilteredTimelineSet(); if (this.hasEventInTimeline(eventId)) {
return timelineSet.getTimelineForEvent(eventId) === this.liveTimeline; const mEvent = this.findEventById(eventId);
if (!mEvent) return;
this.matrixClient.sendReadReceipt(mEvent);
this.emit(cons.events.roomTimeline.MARKED_AS_READ, mEvent);
}
} }
hasEventInActiveTimeline(eventId) { hasEventInTimeline(eventId, timeline = this.activeTimeline) {
const timelineSet = this.getUnfilteredTimelineSet(); const timelineSet = this.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) === this.activeTimeline; const eventTimeline = timelineSet.getTimelineForEvent(eventId);
if (!eventTimeline) return false;
return isTimelineLinked(eventTimeline, timeline);
} }
getUnfilteredTimelineSet() { getUnfilteredTimelineSet() {
@ -242,6 +258,22 @@ class RoomTimeline extends EventEmitter {
return [...new Set(readers)]; return [...new Set(readers)];
} }
getUnreadEventIndex(readUpToEventId) {
if (!this.hasEventInTimeline(readUpToEventId)) return -1;
const readUpToEvent = this.findEventByIdInTimelineSet(readUpToEventId);
if (!readUpToEvent) return -1;
const rTs = readUpToEvent.getTs();
const tLength = this.timeline.length;
for (let i = 0; i < tLength; i += 1) {
const mEvent = this.timeline[i];
if (mEvent.getTs() > rTs) return i;
}
return -1;
}
getReadUpToEventId() { getReadUpToEventId() {
return this.room.getEventReadUpTo(this.matrixClient.getUserId()); return this.room.getEventReadUpTo(this.matrixClient.getUserId());
} }
@ -261,7 +293,7 @@ class RoomTimeline extends EventEmitter {
deleteFromTimeline(eventId) { deleteFromTimeline(eventId) {
const i = this.getEventIndex(eventId); const i = this.getEventIndex(eventId);
if (i === -1) return undefined; if (i === -1) return undefined;
return this.timeline.splice(i, 1); return this.timeline.splice(i, 1)[0];
} }
_listenEvents() { _listenEvents() {
@ -306,12 +338,12 @@ class RoomTimeline extends EventEmitter {
this.emit(cons.events.roomTimeline.EVENT, event); this.emit(cons.events.roomTimeline.EVENT, event);
}; };
this._listenRedaction = (event, room) => { this._listenRedaction = (mEvent, room) => {
if (room.roomId !== this.roomId) return; if (room.roomId !== this.roomId) return;
this.deleteFromTimeline(event.getId()); const rEvent = this.deleteFromTimeline(mEvent.event.redacts);
this.editedTimeline.delete(event.getId()); this.editedTimeline.delete(mEvent.event.redacts);
this.reactionTimeline.delete(event.getId()); this.reactionTimeline.delete(mEvent.event.redacts);
this.emit(cons.events.roomTimeline.EVENT); this.emit(cons.events.roomTimeline.EVENT_REDACTED, rEvent, mEvent);
}; };
this._listenTypingEvent = (event, member) => { this._listenTypingEvent = (event, member) => {

View file

@ -92,6 +92,10 @@ const cons = {
PAGINATED: 'PAGINATED', PAGINATED: 'PAGINATED',
TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED', TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED',
LIVE_RECEIPT: 'LIVE_RECEIPT', LIVE_RECEIPT: 'LIVE_RECEIPT',
MARKED_AS_READ: 'MARKED_AS_READ',
EVENT_REDACTED: 'EVENT_REDACTED',
AT_BOTTOM: 'AT_BOTTOM',
SCROLL_TO_LIVE: 'SCROLL_TO_LIVE',
}, },
roomsInput: { roomsInput: {
MESSAGE_SENT: 'MESSAGE_SENT', MESSAGE_SENT: 'MESSAGE_SENT',