Improve Thread View UI (#7063)
This commit is contained in:
parent
351c426d2a
commit
0bae79d3c3
10 changed files with 222 additions and 42 deletions
|
@ -116,3 +116,11 @@ limitations under the License.
|
||||||
border-top: 8px solid $menu-bg-color;
|
border-top: 8px solid $menu-bg-color;
|
||||||
border-right: 8px solid transparent;
|
border-right: 8px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_ContextualMenu_rightAligned {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_ContextualMenu_bottomAligned {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
|
@ -18,20 +18,24 @@ limitations under the License.
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.mx_BaseCard_header {
|
padding-right: 0;
|
||||||
padding: 6px 0;
|
|
||||||
|
|
||||||
.mx_BaseCard_close {
|
.mx_BaseCard_header {
|
||||||
|
padding: 6px 8px 6px 0;
|
||||||
|
|
||||||
|
.mx_BaseCard_close,
|
||||||
|
.mx_BaseCard_back {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_BaseCard_close {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AccessibleButton.mx_BaseCard_back {
|
.mx_ThreadPanel__header {
|
||||||
display: none;
|
width: calc(100% - 60px);
|
||||||
}
|
margin-left: 30px;
|
||||||
|
|
||||||
&__header {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -99,11 +103,39 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_ThreadPanel_button {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: -3px;
|
||||||
|
margin-bottom: auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
content: '';
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
position: absolute;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background: $primary-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_ThreadPanel_OptionsButton::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/context-menu.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AutoHideScrollbar {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_RoomView_messageListWrapper {
|
.mx_RoomView_messageListWrapper {
|
||||||
background-color: $background;
|
background-color: $background;
|
||||||
border-radius: 8px;
|
padding: 8px;
|
||||||
padding-top: 8px;
|
border-radius: inherit;
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_ScrollPanel {
|
.mx_ScrollPanel {
|
||||||
|
@ -116,18 +148,7 @@ limitations under the License.
|
||||||
// Account for scrollbar when hovering
|
// Account for scrollbar when hovering
|
||||||
width: calc(100% - 3px);
|
width: calc(100% - 3px);
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
|
padding-top: 0;
|
||||||
.mx_MessageTimestamp {
|
|
||||||
// We need to add !important here due to some enormous selectors overriding it anyways
|
|
||||||
// See: _EventTile.scss:241
|
|
||||||
left: unset !important;
|
|
||||||
right: 0 !important;
|
|
||||||
top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_line.mx_EventTile_line {
|
|
||||||
position: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadInfo {
|
.mx_ThreadInfo {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -148,4 +169,21 @@ limitations under the License.
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_MessageComposer {
|
||||||
|
background-color: $background;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_ThreadPanel_viewInRoom::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/view-in-room.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_ThreadPanel_copyLinkToThread::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/link.svg');
|
||||||
}
|
}
|
||||||
|
|
|
@ -716,19 +716,10 @@ $left-gutter: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.mx_ScrollPanel {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.mx_RoomView_MessageList {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_senderDetails {
|
.mx_EventTile_senderDetails {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 6px;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -761,22 +752,28 @@ $left-gutter: 64px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
|
|
||||||
.mx_MessageTimestamp {
|
.mx_MessageTimestamp {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 2px !important;
|
||||||
|
top: 1px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_ReactionsRow {
|
.mx_ReactionsRow {
|
||||||
order: 999;
|
order: 999;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
margin-left: 36px;
|
||||||
|
margin-right: 50px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_content {
|
||||||
|
margin-left: 36px;
|
||||||
|
margin-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MessageComposer_sendMessage {
|
.mx_MessageComposer_sendMessage {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -390,6 +390,12 @@ limitations under the License.
|
||||||
padding: 0 0 0 25px;
|
padding: 0 0 0 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:not(.mx_MessageComposer_e2eStatus) {
|
||||||
|
.mx_MessageComposer_wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MessageComposer_button:last-child {
|
.mx_MessageComposer_button:last-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,8 @@ export interface IPosition {
|
||||||
bottom?: number;
|
bottom?: number;
|
||||||
left?: number;
|
left?: number;
|
||||||
right?: number;
|
right?: number;
|
||||||
|
rightAligned?: boolean;
|
||||||
|
bottomAligned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ChevronFace {
|
export enum ChevronFace {
|
||||||
|
@ -346,6 +348,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
||||||
|
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true,
|
||||||
|
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuStyle: CSSProperties = {};
|
const menuStyle: CSSProperties = {};
|
||||||
|
|
|
@ -37,6 +37,15 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||||
import { E2EStatus } from '../../utils/ShieldUtils';
|
import { E2EStatus } from '../../utils/ShieldUtils';
|
||||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||||
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
|
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
|
||||||
|
import { ChevronFace, ContextMenuTooltipButton } from './ContextMenu';
|
||||||
|
import { _t } from '../../languageHandler';
|
||||||
|
import IconizedContextMenu, {
|
||||||
|
IconizedContextMenuOption,
|
||||||
|
IconizedContextMenuOptionList,
|
||||||
|
} from '../views/context_menus/IconizedContextMenu';
|
||||||
|
import { ButtonEvent } from '../views/elements/AccessibleButton';
|
||||||
|
import { copyPlaintext } from '../../utils/strings';
|
||||||
|
import { sleep } from 'matrix-js-sdk/src/utils';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -48,13 +57,28 @@ interface IProps {
|
||||||
initialEvent?: MatrixEvent;
|
initialEvent?: MatrixEvent;
|
||||||
initialEventHighlighted?: boolean;
|
initialEventHighlighted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
thread?: Thread;
|
thread?: Thread;
|
||||||
editState?: EditorStateTransfer;
|
editState?: EditorStateTransfer;
|
||||||
replyToEvent?: MatrixEvent;
|
replyToEvent?: MatrixEvent;
|
||||||
|
threadOptionsPosition: DOMRect | null;
|
||||||
|
copyingPhase: CopyingPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CopyingPhase {
|
||||||
|
Idle,
|
||||||
|
Copying,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenuBelow = (elementRect: DOMRect) => {
|
||||||
|
// align the context menu's icons with the icon which opened the context menu
|
||||||
|
const left = elementRect.left + window.pageXOffset + elementRect.width;
|
||||||
|
const top = elementRect.bottom + window.pageYOffset + 17;
|
||||||
|
const chevronFace = ChevronFace.None;
|
||||||
|
return { left, top, chevronFace };
|
||||||
|
};
|
||||||
|
|
||||||
@replaceableComponent("structures.ThreadView")
|
@replaceableComponent("structures.ThreadView")
|
||||||
export default class ThreadView extends React.Component<IProps, IState> {
|
export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
static contextType = RoomContext;
|
static contextType = RoomContext;
|
||||||
|
@ -64,7 +88,10 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {};
|
this.state = {
|
||||||
|
threadOptionsPosition: null,
|
||||||
|
copyingPhase: CopyingPhase.Idle,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
|
@ -181,6 +208,98 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onThreadOptionsClick = (ev: ButtonEvent): void => {
|
||||||
|
if (this.isThreadOptionsVisible) {
|
||||||
|
this.closeThreadOptions();
|
||||||
|
} else {
|
||||||
|
const position = ev.currentTarget.getBoundingClientRect();
|
||||||
|
this.setState({
|
||||||
|
threadOptionsPosition: position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private closeThreadOptions = (): void => {
|
||||||
|
this.setState({
|
||||||
|
threadOptionsPosition: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private get isThreadOptionsVisible(): boolean {
|
||||||
|
return !!this.state.threadOptionsPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewInRoom = (evt: ButtonEvent): void => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'view_room',
|
||||||
|
event_id: this.props.mxEvent.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: this.props.mxEvent.getRoomId(),
|
||||||
|
});
|
||||||
|
this.closeThreadOptions();
|
||||||
|
};
|
||||||
|
|
||||||
|
private copyLinkToThread = async (evt: ButtonEvent): Promise<void> => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
const matrixToUrl = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
copyingPhase: CopyingPhase.Copying,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSuccessfullyCopied = await copyPlaintext(matrixToUrl);
|
||||||
|
|
||||||
|
if (hasSuccessfullyCopied) {
|
||||||
|
await sleep(500);
|
||||||
|
} else {
|
||||||
|
this.setState({ copyingPhase: CopyingPhase.Failed });
|
||||||
|
await sleep(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ copyingPhase: CopyingPhase.Idle });
|
||||||
|
|
||||||
|
if (hasSuccessfullyCopied) {
|
||||||
|
this.closeThreadOptions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private renderThreadViewHeader = (): JSX.Element => {
|
||||||
|
return <div className="mx_ThreadPanel__header">
|
||||||
|
<span>{ _t("Thread") }</span>
|
||||||
|
<ContextMenuTooltipButton
|
||||||
|
className="mx_ThreadPanel_button mx_ThreadPanel_OptionsButton"
|
||||||
|
onClick={this.onThreadOptionsClick}
|
||||||
|
title={_t("Thread options")}
|
||||||
|
isExpanded={this.isThreadOptionsVisible}
|
||||||
|
/>
|
||||||
|
{ this.isThreadOptionsVisible && (<IconizedContextMenu
|
||||||
|
onFinished={this.closeThreadOptions}
|
||||||
|
className="mx_RoomTile_contextMenu"
|
||||||
|
compact
|
||||||
|
rightAligned
|
||||||
|
{...contextMenuBelow(this.state.threadOptionsPosition)}
|
||||||
|
>
|
||||||
|
<IconizedContextMenuOptionList>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
onClick={(e) => this.viewInRoom(e)}
|
||||||
|
label={_t("View in room")}
|
||||||
|
iconClassName="mx_ThreadPanel_viewInRoom"
|
||||||
|
/>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
onClick={(e) => this.copyLinkToThread(e)}
|
||||||
|
label={_t("Copy link to thread")}
|
||||||
|
iconClassName="mx_ThreadPanel_copyLinkToThread"
|
||||||
|
/>
|
||||||
|
</IconizedContextMenuOptionList>
|
||||||
|
</IconizedContextMenu>) }
|
||||||
|
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
const highlightedEventId = this.props.initialEventHighlighted
|
const highlightedEventId = this.props.initialEventHighlighted
|
||||||
? this.props.initialEvent?.getId()
|
? this.props.initialEvent?.getId()
|
||||||
|
@ -193,10 +312,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<BaseCard
|
<BaseCard
|
||||||
className="mx_ThreadView"
|
className="mx_ThreadView mx_ThreadPanel"
|
||||||
onClose={this.props.onClose}
|
onClose={this.props.onClose}
|
||||||
previousPhase={RightPanelPhases.ThreadPanel}
|
previousPhase={RightPanelPhases.ThreadPanel}
|
||||||
withoutScrollContainer={true}
|
withoutScrollContainer={true}
|
||||||
|
header={this.renderThreadViewHeader()}
|
||||||
>
|
>
|
||||||
{ this.state.thread && (
|
{ this.state.thread && (
|
||||||
<TimelinePanel
|
<TimelinePanel
|
||||||
|
@ -209,7 +329,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
showUrlPreview={true}
|
showUrlPreview={true}
|
||||||
tileShape={TileShape.Thread}
|
tileShape={TileShape.Thread}
|
||||||
empty={<div>empty</div>}
|
empty={<div>empty</div>}
|
||||||
alwaysShowTimestamps={true}
|
|
||||||
layout={Layout.Group}
|
layout={Layout.Group}
|
||||||
hideThreadedMessages={false}
|
hideThreadedMessages={false}
|
||||||
hidden={false}
|
hidden={false}
|
||||||
|
|
|
@ -128,7 +128,10 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
|
||||||
name="threadsButton"
|
name="threadsButton"
|
||||||
title={_t("Threads")}
|
title={_t("Threads")}
|
||||||
onClick={dispatchShowThreadsPanelEvent}
|
onClick={dispatchShowThreadsPanelEvent}
|
||||||
isHighlighted={this.isPhase(RightPanelPhases.ThreadPanel)}
|
isHighlighted={this.isPhase([
|
||||||
|
RightPanelPhases.ThreadPanel,
|
||||||
|
RightPanelPhases.ThreadView,
|
||||||
|
])}
|
||||||
analytics={['Right Panel', 'Threads List Button', 'click']}
|
analytics={['Right Panel', 'Threads List Button', 'click']}
|
||||||
/> }
|
/> }
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
|
|
|
@ -1251,6 +1251,8 @@ export default class EventTile extends React.Component<IProps, IState> {
|
||||||
"aria-atomic": true,
|
"aria-atomic": true,
|
||||||
"data-scroll-tokens": scrollToken,
|
"data-scroll-tokens": scrollToken,
|
||||||
"data-has-reply": !!replyChain,
|
"data-has-reply": !!replyChain,
|
||||||
|
"onMouseEnter": () => this.setState({ hover: true }),
|
||||||
|
"onMouseLeave": () => this.setState({ hover: false }),
|
||||||
}, [
|
}, [
|
||||||
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
|
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
|
||||||
<RoomAvatar room={room} width={28} height={28} />
|
<RoomAvatar room={room} width={28} height={28} />
|
||||||
|
@ -1262,7 +1264,6 @@ export default class EventTile extends React.Component<IProps, IState> {
|
||||||
{ avatar }
|
{ avatar }
|
||||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||||
{ sender }
|
{ sender }
|
||||||
{ timestamp }
|
|
||||||
</a>
|
</a>
|
||||||
</div>,
|
</div>,
|
||||||
<div className={lineClasses} key="mx_EventTile_line">
|
<div className={lineClasses} key="mx_EventTile_line">
|
||||||
|
@ -1278,6 +1279,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
||||||
replacingEventId={this.props.replacingEventId}
|
replacingEventId={this.props.replacingEventId}
|
||||||
/>
|
/>
|
||||||
{ actionBar }
|
{ actionBar }
|
||||||
|
{ timestamp }
|
||||||
</div>,
|
</div>,
|
||||||
reactionsRow,
|
reactionsRow,
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -640,6 +640,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
"mx_MessageComposer": true,
|
"mx_MessageComposer": true,
|
||||||
"mx_GroupLayout": true,
|
"mx_GroupLayout": true,
|
||||||
"mx_MessageComposer--compact": this.props.compact,
|
"mx_MessageComposer--compact": this.props.compact,
|
||||||
|
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -3006,6 +3006,8 @@
|
||||||
"All threads": "All threads",
|
"All threads": "All threads",
|
||||||
"Shows all threads from current room": "Shows all threads from current room",
|
"Shows all threads from current room": "Shows all threads from current room",
|
||||||
"Show:": "Show:",
|
"Show:": "Show:",
|
||||||
|
"Thread options": "Thread options",
|
||||||
|
"Copy link to thread": "Copy link to thread",
|
||||||
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
|
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
|
||||||
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
|
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
|
||||||
"Failed to load timeline position": "Failed to load timeline position",
|
"Failed to load timeline position": "Failed to load timeline position",
|
||||||
|
|
Loading…
Reference in a new issue