forked from github/cinny
* Add users on the nav to showcase call activity and who is in the call
* add check to prevent DCing from the call you're currently in...
* Add avatar and username for the space (needs to be moved into RoomNavItem proper)
* Add background variant to buttons
* Update hook to keep method signature (accepting an array of Rooms instead) to support multiple room event tracking of the same event
* Add state listener so the call activity is real time updated on joins/leaves within the space
* Add RoomNavUser for displaying the user avatar + name in the nav for a visual of call activity and participants
* rename CallNavBottom to CallNavStatus
* Rename callnavbottom and fix linking implementation to actually be correct
* temp fix to allow the status to be cleared in some way
* re-add background to active call link button
* prepare to feed this to child elements for visibility handling
* loosely provide nav handling for testing refactoring
* Add CallView
* Update to funnel Outlet context through for Call handling (might not be the best approach, but removes code replication in PersistentCallContainer where we were remaking the roomview entirely)
* update client layout to funnel outlet the iframes for the call container
* funnel through just iframe for now for testing sake
* Update room to use CallView
* Pass forward the backupIframeRef now
* remove unused params
* Add backupIframeRef so we can re-add the lobby screen for non-joined calls (for viewing their text channels)
* Remove unused imports and restructure to support being parent to clientlayout
* Re-add layout as we're no longer oddly passing outlet context
* swap to using ref provider context from to connect to persistentcallcontainer more directly
* Revert to original code as we've moved calling to be more inline with design
* Revert to original code as we've moved the outlet context passing out and made more direct use of the ref
* Fix unexpected visibility in non-room areas
* correctly provide visibility
* re-add mobile chat handling
* Improve call room view stability
* split into two refs
* add ViewedRoom usage
* Disable
* add roomViewId and related
* (broken) juggle the iframe states proper... still needs fixing
* Conditionals to manage the active iframe state better
* add navigateRoom to be in both conditions for the nav button
* Fix the view to correctly display the active iframe based on which is currently hosting the active call (juggling views)
* Testing the iframe juggling. Seems to work for the first and second joins... so likely on the right path with this
* add url as a param for widget url
* fix backup iframe visibility
* Much closer to the call state handling we want w/ hangups and joins
* Fix the position of the member drawer to its correct location
* Ensure drawer doesn't appear in call room
* Better handling of the isCallActive in the join handler
* Add ideal call room join behavior where text rooms to call room simply joins, but doesn't swap current view
* Fix mobile call room default behavior from auto-join to displaying lobby
* swap call status to be bound to call state and not active call id
* Remove clean room ID and add default handler for if no active call has existed yet, but user clicks on show chat
* Applies the correct changes to the call state and removes listeners of old active widget so we don't trigger hang ups on the new one (the element-call widget likes to spam the hang up response back several times for some reason long after you tell it to hang up)
* Remove superfluous comments and Date.now() that was causing loading... bug when widgetId desynced
* Remove Date.now() that was causing widgetId desync
* add listener clearing, camel case es lint rule exception, remove unneeded else statements
* Remove unused
* Add widgetId as a getWidgetUrl param
* Remove no longer needed files
* revert ternary expression change and add to dependency array
* add widgetId to correct pos in getWidgetUrl usage
* Remove CallActivation
* Move and rename RoomCallNavStatus
* update imports and dependency array
* Rename and clean up
* Moved CallProvider
* Fix spelling mistake
* Fix to use shorthand prop
* Remove unneeded logger.errors
* Fixes element-call embedded support (but it seems to run poorly)
* null the default url so that we fallback to the embedded version (would recommend hosting it until performance issue is determined)
* Fix vite build to place element-call correctly for embedded npm package support
* add vite preview as an npm script
* Move files to more correct location
* Add package-lock changes
* Set dep version to exact
* Fix path issue from moving file locations
* Sets initial states so the iframes don't cause the other to fail with the npm embedded package
* Revert navitem change
* Just check for state on both which should only occur at initial
* Fixes call initializing by default on mobile
* Provides correct behavior when call isn't active and no activeClientWidgetApi exists yet
* Corrects the state for the situations where both iframes are "active" (not necessarily visible)
* Reduce code reuse in handleJoin
* Seems to sort out the hangup status button bug the occurred after joining a call via lobby
* Re-add the default view current active room behavior
* Remove repetitive check
* Add storing widget for comparing with (since we already store room id and the clientWidgetApi anyway)
* Update rendering logic to clear up remaining rendering bug (straight to call -> lobby of another room and joining call from that interface -> lobby of that previous room and joining was leading to duplication of the user in lobbies. This was actually from listening to and acknowledging hangups from the viewed widget in CallProvider)
* Prevent null rooms from ever rendering
* This seems to manage the hangup state with the status bar button well enough that black screens should never be encountered
* Remove viewed room setting here and pass the room to hang up (seems state doesn't update fast enough otherwise)
* Remove unused
* Properly declare new hangup method sig
* Seems to avoid almost all invalid states (hang up while viewing another lobby and hitting join seems to black screen, sets the active call as the previous active room id, but does join the viewed room correctly)
* Fix for cases where you're viewing a lobby and hang up your existing call and try to join lobby (was not rendering for the correct room id, but was joining the correct call prior)
* Re-add intended switching behavior
* More correct filter (viewedRoom can return false on that compare in some cases)
* Seems to shore up the remaining state issues with the status bar hangup
* Fix formatting
* In widget hang up button should be handled correct now
* Solves the CHCH sequence issue, CLJH remains
* Fixes CLJH, found CCH
* Solves CCH. Looks like CLCH left
* A bit of an abomination, but adds a state counter to iteratively handle the diverse potential states (where a user can join from the nav bar or the join button, hang up from either as well, and account for the juggling iframes)
Black screens shouldn't be occurring now.
* Fix dependency array
* Technically corrects the hangup button in the widget, should be more precise though
* Bind the on messaging iframe for easier access in hangup/join handling
* Far cleaner and more sensible handling of the call window... I just really don't like the idea of sending a click event, but right now the element-call code treats preload/skipLobby hangups (sent from our end) as if they had no lobby at all and thus black screens. Other implementation was working around that without just sending a click event on the iframe's hangup button.
* Fixes a bug where if you left a call then went to a lobby and joined it didn't update the actual activeCallRoomId
* Fixes complaints of null contentDocument in iframe
* Update to use new icons (thank you)
* Remove unneeded prop
* Re-arrange more options and add checks for each option to see if it is a call room (probably should manage a state to see if a header is already on screen and provide a slightly modified visual based on that for call rooms)
* Invert icons to show the state instead of the action they will perform (more visual clarity)
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/pages/client/space/Space.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* adjust room header for calling
* Remove No Active Call text when not in a call
* update element-call version
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
* Revert most changes to Space.tsx
* Show call room even if category is collapsed
* changes to RoomNavItem, RoomNavUser and add useCallMembers
* Rename file, sprinkle in the magic one line for matrixRTCSession. and remove comment block
* swap userId to callMembership as a prop and add a nullchecked userId that uses the membership sender
* update references to use callMembership instead
* Simplify RoomNavUser
Discard future functionality since it probably won't exist in time for merging this PR
* Simplify RoomViewHeader.tsx
Remove unused UI elements that don't have implemented functionality. Replace custom function for checking if a room is direct with a standard hook.
* Update Room.tsx to accomodate restructuring of Room, RoomView and CallView
* Update RoomView.tsx to accomodate restructuring of Room, RoomView and CallView
* Update CallView.tsx to accomodate restructuring of Room, RoomView and CallView + suggested changes
* add call related permissions to room permissions
* bump element call to 0.16.3, apply cinny theme to element call ui, replace element call lobby (backup iframe) with custom ui and only use element call for the in-call ui
* update text spacing
* redo roomcallnavstatus ui, force user preferred mute/video states when first joining calls, update variable names and remove unnecessary logic
* set default mic state to enabled
* clean up ts/eslint errors
* remove debug logs
* format using prettier rules from project prettierrc
* fix: show call nav status while active call is ongoing
* fix: clean up call nav/call view console warnings
* fix: keep call media controls visible before joining
* fix: restore header icon button fill behavior
Fixes regression from b074d421b66eb4d8b600dfa55b967e6c4f783044.
* style: blend header and room input button styles in call nav
* fix page header background color on room view header
* fix: permissions and room icon resolution (#2)
* Initialize call state upon room creation for call rooms, remove subsequent useless permission
* handle case of missing call permissions
* use call icon for room item summary when room is call room
* replace previous icon src resolution function with a more robust approach
* replace usages of previous icon resolution function with new implementation
* fix room name not updating for a while when changed
* set up framework for room power level overrides upon room creation
* override join call permission to all members upon room creation
* fix broken usages of RoomIcon
* remove unneeded import
* remove unnecessary logic
* format with prettier
* feat: show connected/connecting call status
* fix: preserve navigation context when opening non-call rooms
* fix: reset room name state when room instance changes
* feat: Disable webcam by default using callIntent='audio'
* Add channel type selecor
* Add option for voice rooms, which for now sets the default selected
option in the creation modal
* Add proper support for room selection from the enu
* Move enums to `types.ts` and change icons selection to use
`getRoomIconSrc`
* fix: group duplicate conditions into one
* fix: typo
* refactor: rename kind/voice to access/type and simplify room creation
- rename CreateRoomVoice to CreateRoomType and modal voice state to type
- rename CreateRoomKind to CreateRoomAccess and KindSelector to AccessSelector
- propagate access/defaultAccess through create room and create space forms
- set voice room power levels via createRoom power_level_content_override
* refactor: unify join rule icon mapping and update call/space icons
- bump folds from 2.5.0 to 2.6.0
- replace separate room/space join-rule icon hooks with useJoinRuleIcons(roomType)
- route join-rule icons through getRoomIconSrc for consistent room type handling
- simplify getRoomIconSrc by removing the locked override path
- use VolumeHighGlobe for public call rooms and VolumeHighLock for private call rooms
* chore(deps): bump matrix-widget-api to 1.17 and remove react-sdk-module-api
* fix: adapt SmallWidget to matrix-widget-api 1.17.0 API
* fix: render call room chat only when chat panel is open
* fix(permissions): show call settings permissions only for call rooms
* refactor: remove redundant room-nav props/guards and minor naming cleanup
* fix: use PhoneDown icon for hang up action
* chore(hooks): remove unused useStateEvents hook
* fix(room): enable members drawer toggle in desktop call rooms
- show filled User icon when the drawer is open
* Revert "fix: adapt SmallWidget to matrix-widget-api 1.17.0 API"
This reverts commit a4c34eff8a.
* fix: semi-revert matrix-widget-api 1.17 bump and migrate to 1.13 API
* fix(call): wait for Element Call contentLoaded before widget handshake
- fixes not working on firefox
* fix missing imports
* improve create room type design and add beta badge for voice room
* add beta badge for voice room in space lobby
* fix create room modal title
* pass missing roomType param to roomicon component
* add roomtype
* Add deafen functionality (#2695)
* feat:(deafen functionality)
* feat:(reworked voice controls for deafen)
* ref:(use muted instead of volume for deafen)
* fix:(backpedal audio_enabled rename)
* ref:(renaming of deafened vars)
* add stack avatar component
* add call status bar - WIP
* remove call status from navigation drawer
* fix deprecated method use in use call members hook
* render new call status bar
* move call widget driver to plugins
* remove old status bar usage from navigation drawer
* add call session and joined hook
* remove unknown changes
* upgrade widget api
* add element call embed plugin
* remove unknown change
* add call embed atom
* add call embed hooks and context
* add call embed provider
* replace old call implementation
* stop joining other call on second click if already in a call
* refactor embed placement hook
* add merge border prop to sequence card
* add call preferences
* add prescreen to call view - WIP
* prevent joining new call if already in call
* make call layout adaptive
* render call chat as right panel
* show call members in prescreen
* render call join leave event in timeline
* remove unknown rewrite in docker-nginx file
* render call event without hidden event enable
---------
Co-authored-by: Gigiaj <gigiaboone@yahoo.com>
Co-authored-by: Jaggar <18173108+GigiaJ@users.noreply.github.com>
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
Co-authored-by: Gimle Larpes <gimlelarpes@gmail.com>
Co-authored-by: YoJames2019 <jamesclark1700@gmail.com>
Co-authored-by: YoJames2019 <yobiscuit0@gmail.com>
Co-authored-by: hazre <mail@haz.re>
Co-authored-by: haz <37149950+hazre@users.noreply.github.com>
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Co-authored-by: James <49845975+YoJames2019@users.noreply.github.com>
Co-authored-by: James Reilly <jreilly1821@gmail.com>
Co-authored-by: Tymek <vonautymek@gmail.com>
Co-authored-by: Thedustbuster <92692948+Thedustbustr@users.noreply.github.com>
1840 lines
62 KiB
TypeScript
1840 lines
62 KiB
TypeScript
/* eslint-disable react/destructuring-assignment */
|
|
import React, {
|
|
Dispatch,
|
|
MouseEventHandler,
|
|
RefObject,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Direction,
|
|
EventTimeline,
|
|
EventTimelineSet,
|
|
EventTimelineSetHandlerMap,
|
|
IContent,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomEventHandlerMap,
|
|
} from 'matrix-js-sdk';
|
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
|
import classNames from 'classnames';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { Editor } from 'slate';
|
|
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
|
import to from 'await-to-js';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Chip,
|
|
ContainerColor,
|
|
Icon,
|
|
Icons,
|
|
Line,
|
|
Scroll,
|
|
Text,
|
|
as,
|
|
color,
|
|
config,
|
|
toRem,
|
|
} from 'folds';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
|
import { useAlive } from '../../hooks/useAlive';
|
|
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
|
import {
|
|
DefaultPlaceholder,
|
|
CompactPlaceholder,
|
|
Reply,
|
|
MessageBase,
|
|
MessageUnsupportedContent,
|
|
Time,
|
|
MessageNotDecryptedContent,
|
|
RedactedContent,
|
|
MSticker,
|
|
ImageContent,
|
|
EventContent,
|
|
} from '../../components/message';
|
|
import {
|
|
factoryRenderLinkifyWithMention,
|
|
getReactCustomHtmlParser,
|
|
LINKIFY_OPTS,
|
|
makeMentionCustomProps,
|
|
renderMatrixMention,
|
|
} from '../../plugins/react-custom-html-parser';
|
|
import {
|
|
canEditEvent,
|
|
decryptAllTimelineEvent,
|
|
getEditedEvent,
|
|
getEventReactions,
|
|
getLatestEditableEvt,
|
|
getMemberDisplayName,
|
|
getReactionContent,
|
|
isMembershipChanged,
|
|
reactionOrEditEvent,
|
|
} from '../../utils/room';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { MessageLayout, settingsAtom } from '../../state/settings';
|
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
|
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
|
import { RoomIntro } from '../../components/room-intro';
|
|
import {
|
|
getIntersectionObserverEntry,
|
|
useIntersectionObserver,
|
|
} from '../../hooks/useIntersectionObserver';
|
|
import { markAsRead } from '../../utils/notifications';
|
|
import { useDebounce } from '../../hooks/useDebounce';
|
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
|
import * as css from './RoomTimeline.css';
|
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
|
import { Image } from '../../components/media';
|
|
import { ImageViewer } from '../../components/image-viewer';
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
import { useRoomUnread } from '../../state/hooks/unread';
|
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
|
import { useTheme } from '../../hooks/useTheme';
|
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
|
|
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
|
({ position, className, ...props }, ref) => (
|
|
<Box
|
|
className={classNames(css.TimelineFloat({ position }), className)}
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
gap="200"
|
|
{...props}
|
|
ref={ref}
|
|
/>
|
|
)
|
|
);
|
|
|
|
const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
|
|
({ variant, children, ...props }, ref) => (
|
|
<Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
|
|
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
|
{children}
|
|
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
|
</Box>
|
|
)
|
|
);
|
|
|
|
export const getLiveTimeline = (room: Room): EventTimeline =>
|
|
room.getUnfilteredTimelineSet().getLiveTimeline();
|
|
|
|
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
|
|
const timelineSet = room.getUnfilteredTimelineSet();
|
|
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
|
|
};
|
|
|
|
export const getFirstLinkedTimeline = (
|
|
timeline: EventTimeline,
|
|
direction: Direction
|
|
): EventTimeline => {
|
|
const linkedTm = timeline.getNeighbouringTimeline(direction);
|
|
if (!linkedTm) return timeline;
|
|
return getFirstLinkedTimeline(linkedTm, direction);
|
|
};
|
|
|
|
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
|
|
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
|
|
const timelines: EventTimeline[] = [];
|
|
|
|
for (
|
|
let nextTimeline: EventTimeline | null = firstTimeline;
|
|
nextTimeline;
|
|
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
|
|
) {
|
|
timelines.push(nextTimeline);
|
|
}
|
|
return timelines;
|
|
};
|
|
|
|
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
|
|
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
|
|
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
|
|
count + timelineToEventsCount(tm);
|
|
return timelines.reduce(timelineEventCountReducer, 0);
|
|
};
|
|
|
|
export const getTimelineAndBaseIndex = (
|
|
timelines: EventTimeline[],
|
|
index: number
|
|
): [EventTimeline | undefined, number] => {
|
|
let uptoTimelineLen = 0;
|
|
const timeline = timelines.find((t) => {
|
|
uptoTimelineLen += t.getEvents().length;
|
|
if (index < uptoTimelineLen) return true;
|
|
return false;
|
|
});
|
|
if (!timeline) return [undefined, 0];
|
|
return [timeline, uptoTimelineLen - timeline.getEvents().length];
|
|
};
|
|
|
|
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
|
|
absoluteIndex - timelineBaseIndex;
|
|
|
|
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
|
|
timeline.getEvents()[index];
|
|
|
|
export const getEventIdAbsoluteIndex = (
|
|
timelines: EventTimeline[],
|
|
eventTimeline: EventTimeline,
|
|
eventId: string
|
|
): number | undefined => {
|
|
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
|
|
if (timelineIndex === -1) return undefined;
|
|
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
|
|
if (eventIndex === -1) return undefined;
|
|
const baseIndex = timelines
|
|
.slice(0, timelineIndex)
|
|
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
|
|
return baseIndex + eventIndex;
|
|
};
|
|
|
|
type RoomTimelineProps = {
|
|
room: Room;
|
|
eventId?: string;
|
|
roomInputRef: RefObject<HTMLElement>;
|
|
editor: Editor;
|
|
};
|
|
|
|
const PAGINATION_LIMIT = 80;
|
|
|
|
type Timeline = {
|
|
linkedTimelines: EventTimeline[];
|
|
range: ItemRange;
|
|
};
|
|
|
|
const useEventTimelineLoader = (
|
|
mx: MatrixClient,
|
|
room: Room,
|
|
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
|
onError: (err: Error | null) => void
|
|
) => {
|
|
const loadEventTimeline = useCallback(
|
|
async (eventId: string) => {
|
|
const [err, replyEvtTimeline] = await to(
|
|
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
|
|
);
|
|
if (!replyEvtTimeline) {
|
|
onError(err ?? null);
|
|
return;
|
|
}
|
|
const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
|
|
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
|
|
|
if (absIndex === undefined) {
|
|
onError(err ?? null);
|
|
return;
|
|
}
|
|
|
|
onLoad(eventId, linkedTimelines, absIndex);
|
|
},
|
|
[mx, room, onLoad, onError]
|
|
);
|
|
|
|
return loadEventTimeline;
|
|
};
|
|
|
|
const useTimelinePagination = (
|
|
mx: MatrixClient,
|
|
timeline: Timeline,
|
|
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
|
limit: number
|
|
) => {
|
|
const timelineRef = useRef(timeline);
|
|
timelineRef.current = timeline;
|
|
const alive = useAlive();
|
|
|
|
const handleTimelinePagination = useMemo(() => {
|
|
let fetching = false;
|
|
|
|
const recalibratePagination = (
|
|
linkedTimelines: EventTimeline[],
|
|
timelinesEventsCount: number[],
|
|
backwards: boolean
|
|
) => {
|
|
const topTimeline = linkedTimelines[0];
|
|
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
|
|
|
const newLTimelines = getLinkedTimelines(topTimeline);
|
|
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
|
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
|
|
|
const topTmAddedEvt =
|
|
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
|
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
|
|
|
setTimeline((currentTimeline) => ({
|
|
linkedTimelines: newLTimelines,
|
|
range:
|
|
offsetRange > 0
|
|
? {
|
|
start: currentTimeline.range.start + offsetRange,
|
|
end: currentTimeline.range.end + offsetRange,
|
|
}
|
|
: { ...currentTimeline.range },
|
|
}));
|
|
};
|
|
|
|
return async (backwards: boolean) => {
|
|
if (fetching) return;
|
|
const { linkedTimelines: lTimelines } = timelineRef.current;
|
|
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
|
|
|
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
|
if (!timelineToPaginate) return;
|
|
|
|
const paginationToken = timelineToPaginate.getPaginationToken(
|
|
backwards ? Direction.Backward : Direction.Forward
|
|
);
|
|
if (
|
|
!paginationToken &&
|
|
getTimelinesEventsCount(lTimelines) !==
|
|
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
|
) {
|
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
|
return;
|
|
}
|
|
|
|
fetching = true;
|
|
const [err] = await to(
|
|
mx.paginateEventTimeline(timelineToPaginate, {
|
|
backwards,
|
|
limit,
|
|
})
|
|
);
|
|
if (err) {
|
|
// TODO: handle pagination error.
|
|
return;
|
|
}
|
|
const fetchedTimeline =
|
|
timelineToPaginate.getNeighbouringTimeline(
|
|
backwards ? Direction.Backward : Direction.Forward
|
|
) ?? timelineToPaginate;
|
|
// Decrypt all event ahead of render cycle
|
|
const roomId = fetchedTimeline.getRoomId();
|
|
const room = roomId ? mx.getRoom(roomId) : null;
|
|
|
|
if (room?.hasEncryptionStateEvent()) {
|
|
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
|
}
|
|
|
|
fetching = false;
|
|
if (alive()) {
|
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
|
}
|
|
};
|
|
}, [mx, alive, setTimeline, limit]);
|
|
return handleTimelinePagination;
|
|
};
|
|
|
|
const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
|
|
useEffect(() => {
|
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
|
mEvent,
|
|
eventRoom,
|
|
toStartOfTimeline,
|
|
removed,
|
|
data
|
|
) => {
|
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
|
onArrive(mEvent);
|
|
};
|
|
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
|
if (eventRoom?.roomId !== room.roomId) return;
|
|
onArrive(mEvent);
|
|
};
|
|
|
|
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
|
room.on(RoomEvent.Redaction, handleRedaction);
|
|
return () => {
|
|
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
|
room.removeListener(RoomEvent.Redaction, handleRedaction);
|
|
};
|
|
}, [room, onArrive]);
|
|
};
|
|
|
|
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
|
|
useEffect(() => {
|
|
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
|
|
if (r.roomId !== room.roomId) return;
|
|
onRefresh();
|
|
};
|
|
|
|
room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
|
|
return () => {
|
|
room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
|
|
};
|
|
}, [room, onRefresh]);
|
|
};
|
|
|
|
const getInitialTimeline = (room: Room) => {
|
|
const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
|
|
const evLength = getTimelinesEventsCount(linkedTimelines);
|
|
return {
|
|
linkedTimelines,
|
|
range: {
|
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
|
end: evLength,
|
|
},
|
|
};
|
|
};
|
|
|
|
const getEmptyTimeline = () => ({
|
|
range: { start: 0, end: 0 },
|
|
linkedTimelines: [],
|
|
});
|
|
|
|
const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
|
|
const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
|
|
if (!readUptoEventId) return undefined;
|
|
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
|
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
|
return {
|
|
readUptoEventId,
|
|
inLiveTimeline: latestTimeline === room.getLiveTimeline(),
|
|
scrollTo,
|
|
};
|
|
};
|
|
|
|
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
|
const direct = useIsDirectRoom();
|
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
|
|
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
|
|
|
const ignoredUsersList = useIgnoredUsers();
|
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
|
|
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
|
const powerLevels = usePowerLevelsContext();
|
|
const creators = useRoomCreators(room);
|
|
|
|
const creatorsTag = useRoomCreatorsTag();
|
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
|
|
|
const theme = useTheme();
|
|
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
|
theme.kind,
|
|
creatorsTag,
|
|
powerLevelTags
|
|
);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
|
|
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
|
const [editId, setEditId] = useState<string>();
|
|
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
|
const spoilerClickHandler = useSpoilerClickHandler();
|
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
|
const space = useSpaceOptionally();
|
|
|
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
|
|
|
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
|
const readUptoEventIdRef = useRef<string>();
|
|
if (unreadInfo) {
|
|
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
|
}
|
|
|
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
|
const [atBottom, setAtBottom] = useState<boolean>(true);
|
|
const atBottomRef = useRef(atBottom);
|
|
atBottomRef.current = atBottom;
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const scrollToBottomRef = useRef({
|
|
count: 0,
|
|
smooth: true,
|
|
});
|
|
|
|
const [focusItem, setFocusItem] = useState<
|
|
| {
|
|
index: number;
|
|
scrollTo: boolean;
|
|
highlight: boolean;
|
|
}
|
|
| undefined
|
|
>();
|
|
const alive = useAlive();
|
|
|
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
|
() => ({
|
|
...LINKIFY_OPTS,
|
|
render: factoryRenderLinkifyWithMention((href) =>
|
|
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
|
|
),
|
|
}),
|
|
[mx, room, mentionClickHandler]
|
|
);
|
|
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
|
() =>
|
|
getReactCustomHtmlParser(mx, room.roomId, {
|
|
linkifyOpts,
|
|
useAuthentication,
|
|
handleSpoilerClick: spoilerClickHandler,
|
|
handleMentionClick: mentionClickHandler,
|
|
}),
|
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
|
|
);
|
|
const parseMemberEvent = useMemberEventParser();
|
|
|
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
|
eventId ? getEmptyTimeline() : getInitialTimeline(room)
|
|
);
|
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
|
const liveTimelineLinked =
|
|
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
|
|
const canPaginateBack =
|
|
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
|
const rangeAtStart = timeline.range.start === 0;
|
|
const rangeAtEnd = timeline.range.end === eventsLength;
|
|
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
|
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
|
|
|
const handleTimelinePagination = useTimelinePagination(
|
|
mx,
|
|
timeline,
|
|
setTimeline,
|
|
PAGINATION_LIMIT
|
|
);
|
|
|
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
|
|
|
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
|
|
useVirtualPaginator({
|
|
count: eventsLength,
|
|
limit: PAGINATION_LIMIT,
|
|
range: timeline.range,
|
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
|
getScrollElement,
|
|
getItemElement: useCallback(
|
|
(index: number) =>
|
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
|
undefined,
|
|
[]
|
|
),
|
|
onEnd: handleTimelinePagination,
|
|
});
|
|
|
|
const loadEventTimeline = useEventTimelineLoader(
|
|
mx,
|
|
room,
|
|
useCallback(
|
|
(evtId, lTimelines, evtAbsIndex) => {
|
|
if (!alive()) return;
|
|
const evLength = getTimelinesEventsCount(lTimelines);
|
|
|
|
setFocusItem({
|
|
index: evtAbsIndex,
|
|
scrollTo: true,
|
|
highlight: evtId !== readUptoEventIdRef.current,
|
|
});
|
|
setTimeline({
|
|
linkedTimelines: lTimelines,
|
|
range: {
|
|
start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
|
|
end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
|
|
},
|
|
});
|
|
},
|
|
[alive]
|
|
),
|
|
useCallback(() => {
|
|
if (!alive()) return;
|
|
setTimeline(getInitialTimeline(room));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = false;
|
|
}, [alive, room])
|
|
);
|
|
|
|
useLiveEventArrive(
|
|
room,
|
|
useCallback(
|
|
(mEvt: MatrixEvent) => {
|
|
// if user is at bottom of timeline
|
|
// keep paginating timeline and conditionally mark as read
|
|
// otherwise we update timeline without paginating
|
|
// so timeline can be updated with evt like: edits, reactions etc
|
|
if (atBottomRef.current) {
|
|
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
|
|
// Check if the document is in focus (user is actively viewing the app),
|
|
// and either there are no unread messages or the latest message is from the current user.
|
|
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
|
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
|
|
}
|
|
|
|
if (!document.hasFocus() && !unreadInfo) {
|
|
setUnreadInfo(getRoomUnreadInfo(room));
|
|
}
|
|
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = true;
|
|
|
|
setTimeline((ct) => ({
|
|
...ct,
|
|
range: {
|
|
start: ct.range.start + 1,
|
|
end: ct.range.end + 1,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
setTimeline((ct) => ({ ...ct }));
|
|
if (!unreadInfo) {
|
|
setUnreadInfo(getRoomUnreadInfo(room));
|
|
}
|
|
},
|
|
[mx, room, unreadInfo, hideActivity]
|
|
)
|
|
);
|
|
|
|
const handleOpenEvent = useCallback(
|
|
async (
|
|
evtId: string,
|
|
highlight = true,
|
|
onScroll: ((scrolled: boolean) => void) | undefined = undefined
|
|
) => {
|
|
const evtTimeline = getEventTimeline(room, evtId);
|
|
const absoluteIndex =
|
|
evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
|
|
|
|
if (typeof absoluteIndex === 'number') {
|
|
const scrolled = scrollToItem(absoluteIndex, {
|
|
behavior: 'smooth',
|
|
align: 'center',
|
|
stopInView: true,
|
|
});
|
|
if (onScroll) onScroll(scrolled);
|
|
setFocusItem({
|
|
index: absoluteIndex,
|
|
scrollTo: false,
|
|
highlight,
|
|
});
|
|
} else {
|
|
setTimeline(getEmptyTimeline());
|
|
loadEventTimeline(evtId);
|
|
}
|
|
},
|
|
[room, timeline, scrollToItem, loadEventTimeline]
|
|
);
|
|
|
|
useLiveTimelineRefresh(
|
|
room,
|
|
useCallback(() => {
|
|
if (liveTimelineLinked) {
|
|
setTimeline(getInitialTimeline(room));
|
|
}
|
|
}, [room, liveTimelineLinked])
|
|
);
|
|
|
|
// Stay at bottom when room editor resize
|
|
useResizeObserver(
|
|
useMemo(() => {
|
|
let mounted = false;
|
|
return (entries) => {
|
|
if (!mounted) {
|
|
// skip initial mounting call
|
|
mounted = true;
|
|
return;
|
|
}
|
|
if (!roomInputRef.current) return;
|
|
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
|
const scrollElement = getScrollElement();
|
|
if (!editorBaseEntry || !scrollElement) return;
|
|
|
|
if (atBottomRef.current) {
|
|
scrollToBottom(scrollElement);
|
|
}
|
|
};
|
|
}, [getScrollElement, roomInputRef]),
|
|
useCallback(() => roomInputRef.current, [roomInputRef])
|
|
);
|
|
|
|
const tryAutoMarkAsRead = useCallback(() => {
|
|
const readUptoEventId = readUptoEventIdRef.current;
|
|
if (!readUptoEventId) {
|
|
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
|
return;
|
|
}
|
|
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
|
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
|
if (latestTimeline === room.getLiveTimeline()) {
|
|
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
|
}
|
|
}, [mx, room, hideActivity]);
|
|
|
|
const debounceSetAtBottom = useDebounce(
|
|
useCallback((entry: IntersectionObserverEntry) => {
|
|
if (!entry.isIntersecting) setAtBottom(false);
|
|
}, []),
|
|
{ wait: 1000 }
|
|
);
|
|
useIntersectionObserver(
|
|
useCallback(
|
|
(entries) => {
|
|
const target = atBottomAnchorRef.current;
|
|
if (!target) return;
|
|
const targetEntry = getIntersectionObserverEntry(target, entries);
|
|
if (targetEntry) debounceSetAtBottom(targetEntry);
|
|
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
|
setAtBottom(true);
|
|
if (document.hasFocus()) {
|
|
tryAutoMarkAsRead();
|
|
}
|
|
}
|
|
},
|
|
[debounceSetAtBottom, tryAutoMarkAsRead]
|
|
),
|
|
useCallback(
|
|
() => ({
|
|
root: getScrollElement(),
|
|
rootMargin: '100px',
|
|
}),
|
|
[getScrollElement]
|
|
),
|
|
useCallback(() => atBottomAnchorRef.current, [])
|
|
);
|
|
|
|
useDocumentFocusChange(
|
|
useCallback(
|
|
(inFocus) => {
|
|
if (inFocus && atBottomRef.current) {
|
|
if (unreadInfo?.inLiveTimeline) {
|
|
handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
|
|
// the unread event is already in view
|
|
// so, try mark as read;
|
|
if (!scrolled) {
|
|
tryAutoMarkAsRead();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
tryAutoMarkAsRead();
|
|
}
|
|
},
|
|
[tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
|
|
)
|
|
);
|
|
|
|
// Handle up arrow edit
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt) => {
|
|
if (
|
|
isKeyHotkey('arrowup', evt) &&
|
|
editableActiveElement() &&
|
|
document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
|
|
isEmptyEditor(editor)
|
|
) {
|
|
const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
|
|
canEditEvent(mx, mEvt)
|
|
);
|
|
const editableEvtId = editableEvt?.getId();
|
|
if (!editableEvtId) return;
|
|
setEditId(editableEvtId);
|
|
evt.preventDefault();
|
|
}
|
|
},
|
|
[mx, room, editor]
|
|
)
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (eventId) {
|
|
setTimeline(getEmptyTimeline());
|
|
loadEventTimeline(eventId);
|
|
}
|
|
}, [eventId, loadEventTimeline]);
|
|
|
|
// Scroll to bottom on initial timeline load
|
|
useLayoutEffect(() => {
|
|
const scrollEl = scrollRef.current;
|
|
if (scrollEl) {
|
|
scrollToBottom(scrollEl);
|
|
}
|
|
}, []);
|
|
|
|
// if live timeline is linked and unreadInfo change
|
|
// Scroll to last read message
|
|
useLayoutEffect(() => {
|
|
const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
|
|
if (readUptoEventId && inLiveTimeline && scrollTo) {
|
|
const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
|
|
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
|
const absoluteIndex =
|
|
evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
|
|
if (absoluteIndex) {
|
|
scrollToItem(absoluteIndex, {
|
|
behavior: 'instant',
|
|
align: 'start',
|
|
stopInView: true,
|
|
});
|
|
}
|
|
}
|
|
}, [room, unreadInfo, scrollToItem]);
|
|
|
|
// scroll to focused message
|
|
useLayoutEffect(() => {
|
|
if (focusItem && focusItem.scrollTo) {
|
|
scrollToItem(focusItem.index, {
|
|
behavior: 'instant',
|
|
align: 'center',
|
|
stopInView: true,
|
|
});
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!alive()) return;
|
|
setFocusItem((currentItem) => {
|
|
if (currentItem === focusItem) return undefined;
|
|
return currentItem;
|
|
});
|
|
}, 2000);
|
|
}, [alive, focusItem, scrollToItem]);
|
|
|
|
// scroll to bottom of timeline
|
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
|
useLayoutEffect(() => {
|
|
if (scrollToBottomCount > 0) {
|
|
const scrollEl = scrollRef.current;
|
|
if (scrollEl)
|
|
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
|
}
|
|
}, [scrollToBottomCount]);
|
|
|
|
// Remove unreadInfo on mark as read
|
|
useEffect(() => {
|
|
if (!unread) {
|
|
setUnreadInfo(undefined);
|
|
}
|
|
}, [unread]);
|
|
|
|
// scroll out of view msg editor in view.
|
|
useEffect(() => {
|
|
if (editId) {
|
|
const editMsgElement =
|
|
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
|
undefined;
|
|
if (editMsgElement) {
|
|
scrollToElement(editMsgElement, {
|
|
align: 'center',
|
|
behavior: 'smooth',
|
|
stopInView: true,
|
|
});
|
|
}
|
|
}
|
|
}, [scrollToElement, editId]);
|
|
|
|
const handleJumpToLatest = () => {
|
|
if (eventId) {
|
|
navigateRoom(room.roomId, undefined, { replace: true });
|
|
}
|
|
setTimeline(getInitialTimeline(room));
|
|
scrollToBottomRef.current.count += 1;
|
|
scrollToBottomRef.current.smooth = false;
|
|
};
|
|
|
|
const handleJumpToUnread = () => {
|
|
if (unreadInfo?.readUptoEventId) {
|
|
setTimeline(getEmptyTimeline());
|
|
loadEventTimeline(unreadInfo.readUptoEventId);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsRead = () => {
|
|
markAsRead(mx, room.roomId, hideActivity);
|
|
};
|
|
|
|
const handleOpenReply: MouseEventHandler = useCallback(
|
|
async (evt) => {
|
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
|
if (!targetId) return;
|
|
handleOpenEvent(targetId);
|
|
},
|
|
[handleOpenEvent]
|
|
);
|
|
|
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
|
if (!userId) {
|
|
console.warn('Button should have "data-user-id" attribute!');
|
|
return;
|
|
}
|
|
openUserRoomProfile(
|
|
room.roomId,
|
|
space?.roomId,
|
|
userId,
|
|
evt.currentTarget.getBoundingClientRect()
|
|
);
|
|
},
|
|
[room, space, openUserRoomProfile]
|
|
);
|
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt) => {
|
|
evt.preventDefault();
|
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
|
if (!userId) {
|
|
console.warn('Button should have "data-user-id" attribute!');
|
|
return;
|
|
}
|
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
|
editor.insertNode(
|
|
createMentionElement(
|
|
userId,
|
|
name.startsWith('@') ? name : `@${name}`,
|
|
userId === mx.getUserId()
|
|
)
|
|
);
|
|
ReactEditor.focus(editor);
|
|
moveCursor(editor);
|
|
},
|
|
[mx, room, editor]
|
|
);
|
|
|
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
|
(evt, startThread = false) => {
|
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
|
if (!replyId) {
|
|
console.warn('Button should have "data-event-id" attribute!');
|
|
return;
|
|
}
|
|
const replyEvt = room.findEventById(replyId);
|
|
if (!replyEvt) return;
|
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
|
const { body, formatted_body: formattedBody } = content;
|
|
const { 'm.relates_to': relation } = startThread
|
|
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
|
: replyEvt.getWireContent();
|
|
const senderId = replyEvt.getSender();
|
|
if (senderId && typeof body === 'string') {
|
|
setReplyDraft({
|
|
userId: senderId,
|
|
eventId: replyId,
|
|
body,
|
|
formattedBody,
|
|
relation,
|
|
});
|
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
|
}
|
|
},
|
|
[room, setReplyDraft, editor]
|
|
);
|
|
|
|
const handleReactionToggle = useCallback(
|
|
(targetEventId: string, key: string, shortcode?: string) => {
|
|
const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
|
|
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
|
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
|
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
|
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
|
|
|
|
if (myReaction && !!myReaction?.isRelation()) {
|
|
mx.redactEvent(room.roomId, myReaction.getId()!);
|
|
return;
|
|
}
|
|
const rShortcode =
|
|
shortcode ||
|
|
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
|
mx.sendEvent(
|
|
room.roomId,
|
|
MessageEvent.Reaction as any,
|
|
getReactionContent(targetEventId, key, rShortcode)
|
|
);
|
|
},
|
|
[mx, room]
|
|
);
|
|
const handleEdit = useCallback(
|
|
(editEvtId?: string) => {
|
|
if (editEvtId) {
|
|
setEditId(editEvtId);
|
|
return;
|
|
}
|
|
setEditId(undefined);
|
|
ReactEditor.focus(editor);
|
|
},
|
|
[editor]
|
|
);
|
|
const { t } = useTranslation();
|
|
|
|
const renderMatrixEvent = useMatrixEventRenderer<
|
|
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
|
>(
|
|
{
|
|
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
|
const hasReactions = reactions && reactions.length > 0;
|
|
const { replyEventId, threadRootId } = mEvent;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
|
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
|
const getContent = (() =>
|
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
|
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderDisplayName =
|
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={editId === mEventId}
|
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
|
canSendReaction={canSendReaction}
|
|
canPinEvent={canPinEvent}
|
|
imagePackRooms={imagePackRooms}
|
|
relations={hasReactions ? reactionRelations : undefined}
|
|
onUserClick={handleUserClick}
|
|
onUsernameClick={handleUsernameClick}
|
|
onReplyClick={handleReplyClick}
|
|
onReactionToggle={handleReactionToggle}
|
|
onEditId={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
replyEventId={replyEventId}
|
|
threadRootId={threadRootId}
|
|
onClick={handleOpenReply}
|
|
getMemberPowerTag={getMemberPowerTag}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(senderId)}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
>
|
|
{mEvent.isRedacted() ? (
|
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
|
) : (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
linkifyOpts={linkifyOpts}
|
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
|
/>
|
|
)}
|
|
</Message>
|
|
);
|
|
},
|
|
[MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
|
const hasReactions = reactions && reactions.length > 0;
|
|
const { replyEventId, threadRootId } = mEvent;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
edit={editId === mEventId}
|
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
|
canSendReaction={canSendReaction}
|
|
canPinEvent={canPinEvent}
|
|
imagePackRooms={imagePackRooms}
|
|
relations={hasReactions ? reactionRelations : undefined}
|
|
onUserClick={handleUserClick}
|
|
onUsernameClick={handleUsernameClick}
|
|
onReplyClick={handleReplyClick}
|
|
onReactionToggle={handleReactionToggle}
|
|
onEditId={handleEdit}
|
|
reply={
|
|
replyEventId && (
|
|
<Reply
|
|
room={room}
|
|
timelineSet={timelineSet}
|
|
replyEventId={replyEventId}
|
|
threadRootId={threadRootId}
|
|
onClick={handleOpenReply}
|
|
getMemberPowerTag={getMemberPowerTag}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
/>
|
|
)
|
|
}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
>
|
|
<EncryptedContent mEvent={mEvent}>
|
|
{() => {
|
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
|
if (mEvent.getType() === MessageEvent.Sticker)
|
|
return (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
if (mEvent.getType() === MessageEvent.RoomMessage) {
|
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
|
const getContent = (() =>
|
|
editedEvent?.getContent()['m.new_content'] ??
|
|
mEvent.getContent()) as GetContentCallback;
|
|
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderDisplayName =
|
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
|
return (
|
|
<RenderMessageContent
|
|
displayName={senderDisplayName}
|
|
msgType={mEvent.getContent().msgtype ?? ''}
|
|
ts={mEvent.getTs()}
|
|
edited={!!editedEvent}
|
|
getContent={getContent}
|
|
mediaAutoLoad={mediaAutoLoad}
|
|
urlPreview={showUrlPreview}
|
|
htmlReactParserOptions={htmlReactParserOptions}
|
|
linkifyOpts={linkifyOpts}
|
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
|
/>
|
|
);
|
|
}
|
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
|
return (
|
|
<Text>
|
|
<MessageNotDecryptedContent />
|
|
</Text>
|
|
);
|
|
return (
|
|
<Text>
|
|
<MessageUnsupportedContent />
|
|
</Text>
|
|
);
|
|
}}
|
|
</EncryptedContent>
|
|
</Message>
|
|
);
|
|
},
|
|
[MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
|
const hasReactions = reactions && reactions.length > 0;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
|
|
return (
|
|
<Message
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
messageSpacing={messageSpacing}
|
|
messageLayout={messageLayout}
|
|
collapse={collapse}
|
|
highlight={highlighted}
|
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
|
canSendReaction={canSendReaction}
|
|
canPinEvent={canPinEvent}
|
|
imagePackRooms={imagePackRooms}
|
|
relations={hasReactions ? reactionRelations : undefined}
|
|
onUserClick={handleUserClick}
|
|
onUsernameClick={handleUsernameClick}
|
|
onReplyClick={handleReplyClick}
|
|
onReactionToggle={handleReactionToggle}
|
|
reactions={
|
|
reactionRelations && (
|
|
<Reactions
|
|
style={{ marginTop: config.space.S200 }}
|
|
room={room}
|
|
relations={reactionRelations}
|
|
mEventId={mEventId}
|
|
canSendReaction={canSendReaction}
|
|
onReactionToggle={handleReactionToggle}
|
|
/>
|
|
)
|
|
}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
|
accessibleTagColors={accessiblePowerTagColors}
|
|
legacyUsernameColor={legacyUsernameColor || direct}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
>
|
|
{mEvent.isRedacted() ? (
|
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
|
) : (
|
|
<MSticker
|
|
content={mEvent.getContent()}
|
|
renderImageContent={(props) => (
|
|
<ImageContent
|
|
{...props}
|
|
autoPlay={mediaAutoLoad}
|
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
</Message>
|
|
);
|
|
},
|
|
[StateEvent.RoomMember]: (mEventId, mEvent, item) => {
|
|
const membershipChanged = isMembershipChanged(mEvent);
|
|
if (membershipChanged && hideMembershipEvents) return null;
|
|
if (!membershipChanged && hideNickAvatarEvents) return null;
|
|
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const parsed = parseMemberEvent(mEvent);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={parsed.icon}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
{parsed.body}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.RoomName]: (mEventId, mEvent, item) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{t('Organisms.RoomCommon.changed_room_name')}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' changed room topic'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Hash}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' changed room avatar'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const callJoined = mEvent.getContent<SessionMembershipData>().application;
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{callJoined ? ' joined the call' : ' ended the call'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
},
|
|
(mEventId, mEvent, item) => {
|
|
if (!showHiddenEvents) return null;
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Code}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' sent '}
|
|
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
|
|
{' state event'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
},
|
|
(mEventId, mEvent, item) => {
|
|
if (!showHiddenEvents) return null;
|
|
if (Object.keys(mEvent.getContent()).length === 0) return null;
|
|
if (mEvent.getRelation()) return null;
|
|
if (mEvent.isRedaction()) return null;
|
|
|
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
|
|
const timeJSX = (
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Event
|
|
key={mEvent.getId()}
|
|
data-message-item={item}
|
|
data-message-id={mEventId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
highlight={highlighted}
|
|
messageSpacing={messageSpacing}
|
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
|
hideReadReceipts={hideActivity}
|
|
showDeveloperTools={showDeveloperTools}
|
|
>
|
|
<EventContent
|
|
messageLayout={messageLayout}
|
|
time={timeJSX}
|
|
iconSrc={Icons.Code}
|
|
content={
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T300" priority="300">
|
|
<b>{senderName}</b>
|
|
{' sent '}
|
|
<code className={customHtmlCss.Code}>{mEvent.getType()}</code>
|
|
{' event'}
|
|
</Text>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Event>
|
|
);
|
|
}
|
|
);
|
|
|
|
let prevEvent: MatrixEvent | undefined;
|
|
let isPrevRendered = false;
|
|
let newDivider = false;
|
|
let dayDivider = false;
|
|
const eventRenderer = (item: number) => {
|
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
|
if (!eventTimeline) return null;
|
|
const timelineSet = eventTimeline?.getTimelineSet();
|
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
|
const mEventId = mEvent?.getId();
|
|
|
|
if (!mEvent || !mEventId) return null;
|
|
|
|
const eventSender = mEvent.getSender();
|
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
|
return null;
|
|
}
|
|
if (mEvent.isRedacted() && !showHiddenEvents) {
|
|
return null;
|
|
}
|
|
|
|
if (!newDivider && readUptoEventIdRef.current) {
|
|
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
|
}
|
|
if (!dayDivider) {
|
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
|
}
|
|
|
|
const collapsed =
|
|
isPrevRendered &&
|
|
!dayDivider &&
|
|
(!newDivider || eventSender === mx.getUserId()) &&
|
|
prevEvent !== undefined &&
|
|
prevEvent.getSender() === eventSender &&
|
|
prevEvent.getType() === mEvent.getType() &&
|
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
|
|
|
const eventJSX = reactionOrEditEvent(mEvent)
|
|
? null
|
|
: renderMatrixEvent(
|
|
mEvent.getType(),
|
|
typeof mEvent.getStateKey() === 'string',
|
|
mEventId,
|
|
mEvent,
|
|
item,
|
|
timelineSet,
|
|
collapsed
|
|
);
|
|
prevEvent = mEvent;
|
|
isPrevRendered = !!eventJSX;
|
|
|
|
const newDividerJSX =
|
|
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
|
<MessageBase space={messageSpacing}>
|
|
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
|
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
|
<Text size="L400">New Messages</Text>
|
|
</Badge>
|
|
</TimelineDivider>
|
|
</MessageBase>
|
|
) : null;
|
|
|
|
const dayDividerJSX =
|
|
dayDivider && eventJSX ? (
|
|
<MessageBase space={messageSpacing}>
|
|
<TimelineDivider variant="Surface">
|
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
|
<Text size="L400">
|
|
{(() => {
|
|
if (today(mEvent.getTs())) return 'Today';
|
|
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
|
return timeDayMonthYear(mEvent.getTs());
|
|
})()}
|
|
</Text>
|
|
</Badge>
|
|
</TimelineDivider>
|
|
</MessageBase>
|
|
) : null;
|
|
|
|
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
|
if (newDividerJSX) newDivider = false;
|
|
if (dayDividerJSX) dayDivider = false;
|
|
|
|
return (
|
|
<React.Fragment key={mEventId}>
|
|
{newDividerJSX}
|
|
{dayDividerJSX}
|
|
{eventJSX}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
return eventJSX;
|
|
};
|
|
|
|
return (
|
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
|
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
|
<TimelineFloat position="Top">
|
|
<Chip
|
|
variant="Primary"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
onClick={handleJumpToUnread}
|
|
>
|
|
<Text size="L400">Jump to Unread</Text>
|
|
</Chip>
|
|
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
onClick={handleMarkAsRead}
|
|
>
|
|
<Text size="L400">Mark as Read</Text>
|
|
</Chip>
|
|
</TimelineFloat>
|
|
)}
|
|
<Scroll ref={scrollRef} visibility="Hover">
|
|
<Box
|
|
direction="Column"
|
|
justifyContent="End"
|
|
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
|
>
|
|
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
|
<div
|
|
style={{
|
|
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
|
|
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
|
|
}`,
|
|
}}
|
|
>
|
|
<RoomIntro room={room} />
|
|
</div>
|
|
)}
|
|
{(canPaginateBack || !rangeAtStart) &&
|
|
(messageLayout === MessageLayout.Compact ? (
|
|
<>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase ref={observeBackAnchor}>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase ref={observeBackAnchor}>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
))}
|
|
|
|
{getItems().map(eventRenderer)}
|
|
|
|
{(!liveTimelineLinked || !rangeAtEnd) &&
|
|
(messageLayout === MessageLayout.Compact ? (
|
|
<>
|
|
<MessageBase ref={observeFrontAnchor}>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<CompactPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MessageBase ref={observeFrontAnchor}>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
<MessageBase>
|
|
<DefaultPlaceholder key={getItems().length} />
|
|
</MessageBase>
|
|
</>
|
|
))}
|
|
<span ref={atBottomAnchorRef} />
|
|
</Box>
|
|
</Scroll>
|
|
{!atBottom && (
|
|
<TimelineFloat position="Bottom">
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
outlined
|
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
|
onClick={handleJumpToLatest}
|
|
>
|
|
<Text size="L400">Jump to Latest</Text>
|
|
</Chip>
|
|
</TimelineFloat>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|