if (mEvent.getType() === 'm.room.member') {
const timelineChange = parseTimelineChange(mEvent);
- if (timelineChange === null) return false;
+ if (timelineChange === null) return <div key={mEvent.getId()} />;
return (
<TimelineChange
key={mEvent.getId()}
let scrollTop = 0;
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;
this._scrollTo(scrollInfo, scrollTop);
}
let timelineScroll = null;
-let focusEventIndex = null;
+let jumpToItemIndex = -1;
const throttle = new Throttle();
const limit = {
from: 0,
},
};
-function useTimeline(roomTimeline, eventId) {
+function useTimeline(roomTimeline, eventId, readEventStore) {
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) => {
if (typeof eId === 'string') {
const isLoaded = await roomTimeline.loadEventTimeline(eId);
};
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);
setEventTimeline(eventId);
return () => {
return timelineInfo;
}
-function usePaginate(roomTimeline, forceUpdateLimit) {
+function usePaginate(roomTimeline, readEventStore, forceUpdateLimit) {
const [info, setInfo] = useState(null);
useEffect(() => {
const handleOnPagination = (backwards, loaded, canLoadMore) => {
if (loaded === 0) return;
+ if (!readEventStore.getItem()) {
+ const readUpToId = roomTimeline.getReadUpToEventId();
+ readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
+ }
limit.setFrom(limit.calcNextFrom(backwards, roomTimeline.timeline.length));
setInfo({
backwards,
return [info, autoPaginate];
}
-function useHandleScroll(roomTimeline, autoPaginate, viewEvent) {
- return useCallback(() => {
+function useHandleScroll(roomTimeline, autoPaginate, readEventStore, forceUpdateLimit) {
+ const handleScroll = useCallback(() => {
requestAnimationFrame(() => {
// emit event to toggle scrollToBottom button visibility
const isAtBottom = (
- timelineScroll.bottom < 16
- && !roomTimeline.canPaginateForward()
- && limit.getEndIndex() === roomTimeline.length
+ timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
+ && limit.getEndIndex() >= roomTimeline.timeline.length
);
- viewEvent.emit('at-bottom', isAtBottom);
+ roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
+ if (isAtBottom && readEventStore.getItem()) {
+ requestAnimationFrame(() => roomTimeline.markAllAsRead());
+ }
});
autoPaginate();
}, [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);
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 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());
+ sendReadReceipt(event);
+ setEvent(event);
}
- setEvent(event);
};
+
+ const handleEventRedact = (event) => setEvent(event);
+
roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
+ roomTimeline.on(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
+ roomTimeline.removeListener(cons.events.roomTimeline.EVENT_REDACTED, handleEventRedact);
};
}, [roomTimeline]);
useEffect(() => {
if (!roomTimeline.initialized) return;
- if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
+ if (timelineScroll.bottom < 16
+ && !roomTimeline.canPaginateForward()
+ && document.visibilityState === 'visible') {
timelineScroll.scrollToBottom();
}
}, [newEvent, roomTimeline]);
}
-function RoomViewContent({
- eventId, roomTimeline, viewEvent,
-}) {
+function RoomViewContent({ eventId, roomTimeline }) {
const timelineSVRef = useRef(null);
const readEventStore = useStore(roomTimeline);
- const timelineInfo = useTimeline(roomTimeline, eventId);
+ const timelineInfo = useTimeline(roomTimeline, eventId, readEventStore);
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
- const [paginateInfo, autoPaginate] = usePaginate(roomTimeline, forceUpdateLimit);
- const handleScroll = useHandleScroll(roomTimeline, autoPaginate, viewEvent);
- useEventArrive(roomTimeline);
+ const [paginateInfo, autoPaginate] = usePaginate(roomTimeline, readEventStore, forceUpdateLimit);
+ const [handleScroll, handleScrollToLive] = useHandleScroll(
+ roomTimeline, autoPaginate, readEventStore, forceUpdateLimit,
+ );
+ useEventArrive(roomTimeline, readEventStore);
const { timeline } = roomTimeline;
- const handleScrollToLive = useCallback(() => {
- if (roomTimeline.isServingLiveTimeline()) {
- timelineScroll.scrollToBottom();
- return;
- }
- roomTimeline.loadLiveTimeline();
- }, [roomTimeline]);
-
useLayoutEffect(() => {
if (!roomTimeline.initialized) {
timelineScroll = new TimelineScroll(timelineSVRef.current);
}
});
+ // when active timeline changes
useEffect(() => {
if (!roomTimeline.initialized) return undefined;
if (timeline.length > 0) {
- if (focusEventIndex === null) timelineScroll.scrollToBottom();
- else timelineScroll.scrollToIndex(focusEventIndex, 80);
- focusEventIndex = null;
+ if (jumpToItemIndex === -1) {
+ timelineScroll.scrollToBottom();
+ } else {
+ timelineScroll.scrollToIndex(jumpToItemIndex, 80);
+ }
+ if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
+ if (readEventStore.getItem()?.getId() === roomTimeline.getReadUpToEventId()) {
+ requestAnimationFrame(() => roomTimeline.markAllAsRead());
+ }
+ }
+ jumpToItemIndex = -1;
}
autoPaginate();
timelineScroll.on('scroll', handleScroll);
- viewEvent.on('scroll-to-live', handleScrollToLive);
+ roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
return () => {
if (timelineSVRef.current === null) return;
timelineScroll.removeListener('scroll', handleScroll);
- viewEvent.removeListener('scroll-to-live', handleScrollToLive);
+ roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
};
}, [timelineInfo]);
+ // when paginating from server
useEffect(() => {
if (!roomTimeline.initialized) return;
timelineScroll.tryRestoringScroll();
autoPaginate();
}, [paginateInfo]);
+ // when paginating locally
useEffect(() => {
if (!roomTimeline.initialized) return;
timelineScroll.tryRestoringScroll();
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 tl = [];
- const readEvent = getReadEvent();
- let extraItemCount = 0;
- focusEventIndex = null;
+ let itemCountIndex = 0;
+ jumpToItemIndex = -1;
+ const readEvent = readEventStore.getItem();
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
- extraItemCount += PLACEHOLDER_COUNT;
+ itemCountIndex += PLACEHOLDER_COUNT;
}
for (let i = limit.from; i < limit.getEndIndex(); i += 1) {
if (i >= timeline.length) break;
if (i === 0 && !roomTimeline.canPaginateBackward()) {
if (mEvent.getType() === 'm.room.create') {
tl.push(genRoomIntro(mEvent, roomTimeline));
+ itemCountIndex += 1;
// eslint-disable-next-line no-continue
continue;
} else {
tl.push(genRoomIntro(undefined, roomTimeline));
- extraItemCount += 1;
+ itemCountIndex += 1;
}
}
+
const unreadDivider = (readEvent
&& prevMEvent?.getTs() <= readEvent.getTs()
&& readEvent.getTs() < mEvent.getTs());
if (unreadDivider) {
- tl.push(<Divider key={`new-${readEvent.getId()}`} variant="positive" text="Unread messages" />);
- if (focusEventIndex === null) focusEventIndex = i + extraItemCount;
+ tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
+ itemCountIndex += 1;
+ if (jumpToItemIndex === -1) jumpToItemIndex = itemCountIndex;
}
const dayDivider = prevMEvent && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate());
if (dayDivider) {
tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
- extraItemCount += 1;
+ itemCountIndex += 1;
}
+
const focusId = timelineInfo.focusEventId;
- const isFocus = focusId === mEvent.getId() && focusId !== readEvent?.getId();
- if (isFocus) focusEventIndex = i + extraItemCount;
+ const isFocus = focusId === mEvent.getId();
+ if (isFocus) jumpToItemIndex = itemCountIndex;
tl.push(renderEvent(roomTimeline, mEvent, prevMEvent, isFocus));
+ itemCountIndex += 1;
}
if (roomTimeline.canPaginateForward() || limit.getEndIndex() < timeline.length) {
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
RoomViewContent.propTypes = {
eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
};
export default RoomViewContent;
import IconButton from '../../atoms/button/IconButton';
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';
const jumpToEvent = () => {
roomTimeline.loadEventTimeline(eventId);
- setEventId(null);
};
- const cancelJumpToEvent = () => {
+ const cancelJumpToEvent = (mEvent) => {
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(() => {
const readEventId = roomTimeline.getReadUpToEventId();
- // we only show "Jump to unread" btn only if the event is not in live timeline.
- // if event is in live timeline
- // we will automatically open the timeline from that event
- if (!roomTimeline.hasEventInLiveTimeline(readEventId)) {
+ // we only show "Jump to unread" btn only if the event is not in timeline.
+ // if event is in timeline
+ // we will automatically open the timeline from that event position
+ if (!readEventId.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
setEventId(readEventId);
}
+ roomTimeline.on(cons.events.roomTimeline.MARKED_AS_READ, cancelJumpToEvent);
return () => {
+ roomTimeline.removeListener(cons.events.roomTimeline.MARKED_AS_READ, cancelJumpToEvent);
setEventId(null);
};
}, [roomTimeline]);
return [typingMembers];
}
-function useScrollToBottom(roomId, viewEvent) {
+function useScrollToBottom(roomTimeline) {
const [isAtBottom, setIsAtBottom] = useState(true);
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
useEffect(() => {
setIsAtBottom(true);
- viewEvent.on('at-bottom', handleAtBottom);
- return () => viewEvent.removeListener('at-bottom', handleAtBottom);
- }, [roomId]);
+ roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
+ return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
+ }, [roomTimeline]);
return [isAtBottom, setIsAtBottom];
}
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 [isAtBottom, setIsAtBottom] = useScrollToBottom(roomId, viewEvent);
+ const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
const handleScrollToBottom = () => {
- viewEvent.emit('scroll-to-live');
+ roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
setIsAtBottom(true);
};
onClick={cancelJumpToEvent}
variant="primary"
size="extra-small"
- src={CrossIC}
+ src={TickMarkIC}
tooltipPlacement="bottom"
- tooltip="Cancel"
+ tooltip="Mark as read"
/>
</div>
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
RoomViewFloating.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
};
export default RoomViewFloating;
}
}
+function isTimelineLinked(tm1, tm2) {
+ let tm = getFirstLinkedTimeline(tm1);
+ while (tm) {
+ if (tm === tm2) return true;
+ tm = tm.nextTimeline;
+ }
+ return false;
+}
+
class RoomTimeline extends EventEmitter {
constructor(roomId) {
super();
this.timeline = [];
// TODO: don't clear these timeline cause there data can be used in other timeline
- // this.reactionTimeline.clear();
- // this.editedTimeline.clear();
+ this.reactionTimeline.clear();
+ this.editedTimeline.clear();
}
addToTimeline(mEvent) {
return Promise.allSettled(decryptionPromises);
}
- markAsRead() {
+ markAllAsRead() {
const readEventId = this.getReadUpToEventId();
if (this.timeline.length === 0) return;
const latestEvent = this.timeline[this.timeline.length - 1];
if (readEventId === latestEvent.getId()) return;
this.matrixClient.sendReadReceipt(latestEvent);
+ this.emit(cons.events.roomTimeline.MARKED_AS_READ, latestEvent);
}
- hasEventInLiveTimeline(eventId) {
- const timelineSet = this.getUnfilteredTimelineSet();
- return timelineSet.getTimelineForEvent(eventId) === this.liveTimeline;
+ markAsRead(eventId) {
+ if (this.hasEventInTimeline(eventId)) {
+ 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();
- return timelineSet.getTimelineForEvent(eventId) === this.activeTimeline;
+ const eventTimeline = timelineSet.getTimelineForEvent(eventId);
+ if (!eventTimeline) return false;
+ return isTimelineLinked(eventTimeline, timeline);
}
getUnfilteredTimelineSet() {
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() {
return this.room.getEventReadUpTo(this.matrixClient.getUserId());
}
deleteFromTimeline(eventId) {
const i = this.getEventIndex(eventId);
if (i === -1) return undefined;
- return this.timeline.splice(i, 1);
+ return this.timeline.splice(i, 1)[0];
}
_listenEvents() {
this.emit(cons.events.roomTimeline.EVENT, event);
};
- this._listenRedaction = (event, room) => {
+ this._listenRedaction = (mEvent, room) => {
if (room.roomId !== this.roomId) return;
- this.deleteFromTimeline(event.getId());
- this.editedTimeline.delete(event.getId());
- this.reactionTimeline.delete(event.getId());
- this.emit(cons.events.roomTimeline.EVENT);
+ const rEvent = this.deleteFromTimeline(mEvent.event.redacts);
+ this.editedTimeline.delete(mEvent.event.redacts);
+ this.reactionTimeline.delete(mEvent.event.redacts);
+ this.emit(cons.events.roomTimeline.EVENT_REDACTED, rEvent, mEvent);
};
this._listenTypingEvent = (event, member) => {