Merge branch 'develop' into widget_state_no_update_invitation_room
This commit is contained in:
commit
f726314fa2
256 changed files with 21125 additions and 16532 deletions
|
@ -303,6 +303,7 @@ export async function uploadFile(
|
|||
progressHandler,
|
||||
abortController,
|
||||
includeFilename: false,
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
if (abortController.signal.aborted) throw new UploadCanceledError();
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded
|
|||
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
|
||||
import ToastStore from "./stores/ToastStore";
|
||||
import { ElementCall } from "./models/Call";
|
||||
import { VoiceBroadcastChunkEventType } from "./voice-broadcast";
|
||||
|
||||
/*
|
||||
* Dispatches:
|
||||
|
@ -77,6 +78,13 @@ const msgTypeHandlers = {
|
|||
[M_LOCATION.altName]: (event: MatrixEvent) => {
|
||||
return TextForEvent.textForLocationEvent(event)();
|
||||
},
|
||||
[MsgType.Audio]: (event: MatrixEvent): string | null => {
|
||||
if (event.getContent()?.[VoiceBroadcastChunkEventType]) {
|
||||
// mute broadcast chunks
|
||||
return null;
|
||||
}
|
||||
return TextForEvent.textForEvent(event);
|
||||
},
|
||||
};
|
||||
|
||||
export const Notifier = {
|
||||
|
|
|
@ -134,7 +134,7 @@ export class PosthogAnalytics {
|
|||
private readonly enabled: boolean = false;
|
||||
private static _instance = null;
|
||||
private platformSuperProperties = {};
|
||||
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
|
||||
public static readonly ANALYTICS_EVENT_TYPE = "im.vector.analytics";
|
||||
private propertiesForNextEvent: Partial<Record<"$set" | "$set_once", UserProperties>> = {};
|
||||
private userPropertyCache: UserProperties = {};
|
||||
private authenticationType: Signup["authenticationType"] = "Other";
|
||||
|
|
27
src/Rooms.ts
27
src/Rooms.ts
|
@ -57,42 +57,47 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void>
|
|||
/**
|
||||
* Marks or unmarks the given room as being as a DM room.
|
||||
* @param {string} roomId The ID of the room to modify
|
||||
* @param {string} userId The user ID of the desired DM
|
||||
room target user or null to un-mark
|
||||
this room as a DM room
|
||||
* @param {string | null} userId The user ID of the desired DM room target user or
|
||||
* null to un-mark this room as a DM room
|
||||
* @returns {object} A promise
|
||||
*/
|
||||
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
|
||||
export async function setDMRoom(roomId: string, userId: string | null): Promise<void> {
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const mDirectEvent = MatrixClientPeg.get().getAccountData(EventType.Direct);
|
||||
let dmRoomMap = {};
|
||||
const currentContent = mDirectEvent?.getContent() || {};
|
||||
|
||||
if (mDirectEvent !== undefined) dmRoomMap = { ...mDirectEvent.getContent() }; // copy as we will mutate
|
||||
const dmRoomMap = new Map(Object.entries(currentContent));
|
||||
let modified = false;
|
||||
|
||||
// remove it from the lists of any others users
|
||||
// (it can only be a DM room for one person)
|
||||
for (const thisUserId of Object.keys(dmRoomMap)) {
|
||||
const roomList = dmRoomMap[thisUserId];
|
||||
for (const thisUserId of dmRoomMap.keys()) {
|
||||
const roomList = dmRoomMap.get(thisUserId) || [];
|
||||
|
||||
if (thisUserId != userId) {
|
||||
const indexOfRoom = roomList.indexOf(roomId);
|
||||
if (indexOfRoom > -1) {
|
||||
roomList.splice(indexOfRoom, 1);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// now add it, if it's not already there
|
||||
if (userId) {
|
||||
const roomList = dmRoomMap[userId] || [];
|
||||
const roomList = dmRoomMap.get(userId) || [];
|
||||
if (roomList.indexOf(roomId) == -1) {
|
||||
roomList.push(roomId);
|
||||
modified = true;
|
||||
}
|
||||
dmRoomMap[userId] = roomList;
|
||||
dmRoomMap.set(userId, roomList);
|
||||
}
|
||||
|
||||
await MatrixClientPeg.get().setAccountData(EventType.Direct, dmRoomMap);
|
||||
// prevent unnecessary calls to setAccountData
|
||||
if (!modified) return;
|
||||
|
||||
await MatrixClientPeg.get().setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -264,10 +264,36 @@ Get an openID token for the current user session.
|
|||
Request: No parameters
|
||||
Response:
|
||||
- The openId token object as described in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridopenidrequest_token
|
||||
|
||||
send_event
|
||||
----------
|
||||
Sends an event in a room.
|
||||
|
||||
Request:
|
||||
- type is the event type to send.
|
||||
- state_key is the state key to send. Omitted if not a state event.
|
||||
- content is the event content to send.
|
||||
|
||||
Response:
|
||||
- room_id is the room ID where the event was sent.
|
||||
- event_id is the event ID of the event which was sent.
|
||||
|
||||
read_events
|
||||
-----------
|
||||
Read events from a room.
|
||||
|
||||
Request:
|
||||
- type is the event type to read.
|
||||
- state_key is the state key to read, or `true` to read all events of the type. Omitted if not a state event.
|
||||
|
||||
Response:
|
||||
- events: Array of events. If none found, this will be an empty array.
|
||||
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { IEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
|
@ -295,6 +321,8 @@ enum Action {
|
|||
SetBotOptions = "set_bot_options",
|
||||
SetBotPower = "set_bot_power",
|
||||
GetOpenIdToken = "get_open_id_token",
|
||||
SendEvent = "send_event",
|
||||
ReadEvents = "read_events",
|
||||
}
|
||||
|
||||
function sendResponse(event: MessageEvent<any>, res: any): void {
|
||||
|
@ -468,13 +496,13 @@ function setWidget(event: MessageEvent<any>, roomId: string | null): void {
|
|||
}
|
||||
}
|
||||
|
||||
function getWidgets(event: MessageEvent<any>, roomId: string): void {
|
||||
function getWidgets(event: MessageEvent<any>, roomId: string | null): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t("You need to be logged in."));
|
||||
return;
|
||||
}
|
||||
let widgetStateEvents = [];
|
||||
let widgetStateEvents: Partial<IEvent>[] = [];
|
||||
|
||||
if (roomId) {
|
||||
const room = client.getRoom(roomId);
|
||||
|
@ -693,6 +721,141 @@ async function getOpenIdToken(event: MessageEvent<any>) {
|
|||
}
|
||||
}
|
||||
|
||||
async function sendEvent(
|
||||
event: MessageEvent<{
|
||||
type: string;
|
||||
state_key?: string;
|
||||
content?: IContent;
|
||||
}>,
|
||||
roomId: string,
|
||||
) {
|
||||
const eventType = event.data.type;
|
||||
const stateKey = event.data.state_key;
|
||||
const content = event.data.content;
|
||||
|
||||
if (typeof eventType !== "string") {
|
||||
sendError(event, _t("Failed to send event"), new Error("Invalid 'type' in request"));
|
||||
return;
|
||||
}
|
||||
const allowedEventTypes = ["m.widgets", "im.vector.modular.widgets", "io.element.integrations.installations"];
|
||||
if (!allowedEventTypes.includes(eventType)) {
|
||||
sendError(event, _t("Failed to send event"), new Error("Disallowed 'type' in request"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content || typeof content !== "object") {
|
||||
sendError(event, _t("Failed to send event"), new Error("Invalid 'content' in request"));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t("You need to be logged in."));
|
||||
return;
|
||||
}
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
sendError(event, _t("This room is not recognised."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateKey !== undefined) {
|
||||
// state event
|
||||
try {
|
||||
const res = await client.sendStateEvent(roomId, eventType, content, stateKey);
|
||||
sendResponse(event, {
|
||||
room_id: roomId,
|
||||
event_id: res.event_id,
|
||||
});
|
||||
} catch (e) {
|
||||
sendError(event, _t("Failed to send event"), e as Error);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// message event
|
||||
sendError(event, _t("Failed to send event"), new Error("Sending message events is not implemented"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvents(
|
||||
event: MessageEvent<{
|
||||
type: string;
|
||||
state_key?: string | boolean;
|
||||
limit?: number;
|
||||
}>,
|
||||
roomId: string,
|
||||
) {
|
||||
const eventType = event.data.type;
|
||||
const stateKey = event.data.state_key;
|
||||
const limit = event.data.limit;
|
||||
|
||||
if (typeof eventType !== "string") {
|
||||
sendError(event, _t("Failed to read events"), new Error("Invalid 'type' in request"));
|
||||
return;
|
||||
}
|
||||
const allowedEventTypes = [
|
||||
"m.room.power_levels",
|
||||
"m.room.encryption",
|
||||
"m.room.member",
|
||||
"m.room.name",
|
||||
"m.widgets",
|
||||
"im.vector.modular.widgets",
|
||||
"io.element.integrations.installations",
|
||||
];
|
||||
if (!allowedEventTypes.includes(eventType)) {
|
||||
sendError(event, _t("Failed to read events"), new Error("Disallowed 'type' in request"));
|
||||
return;
|
||||
}
|
||||
|
||||
let effectiveLimit: number;
|
||||
if (limit !== undefined) {
|
||||
if (typeof limit !== "number" || limit < 0) {
|
||||
sendError(event, _t("Failed to read events"), new Error("Invalid 'limit' in request"));
|
||||
return;
|
||||
}
|
||||
effectiveLimit = Math.min(limit, Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
effectiveLimit = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t("You need to be logged in."));
|
||||
return;
|
||||
}
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
sendError(event, _t("This room is not recognised."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateKey !== undefined) {
|
||||
// state events
|
||||
if (typeof stateKey !== "string" && stateKey !== true) {
|
||||
sendError(event, _t("Failed to read events"), new Error("Invalid 'state_key' in request"));
|
||||
return;
|
||||
}
|
||||
// When `true` is passed for state key, get events with any state key.
|
||||
const effectiveStateKey = stateKey === true ? undefined : stateKey;
|
||||
|
||||
let events: MatrixEvent[] = [];
|
||||
events = events.concat(room.currentState.getStateEvents(eventType, effectiveStateKey as string) || []);
|
||||
events = events.slice(0, effectiveLimit);
|
||||
|
||||
sendResponse(event, {
|
||||
events: events.map((e) => e.getEffectiveEvent()),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
// message events
|
||||
sendError(event, _t("Failed to read events"), new Error("Reading message events is not implemented"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const onMessage = function (event: MessageEvent<any>): void {
|
||||
if (!event.origin) {
|
||||
// stupid chrome
|
||||
|
@ -786,6 +949,12 @@ const onMessage = function (event: MessageEvent<any>): void {
|
|||
} else if (event.data.action === Action.CanSendEvent) {
|
||||
canSendEvent(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === Action.SendEvent) {
|
||||
sendEvent(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === Action.ReadEvents) {
|
||||
readEvents(event, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
|
|
|
@ -41,7 +41,6 @@ import { DefaultTagID } from "../../stores/room-list/models";
|
|||
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import LeftPanel from "./LeftPanel";
|
||||
import PipContainer from "../views/voip/PipContainer";
|
||||
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import NonUrgentToastContainer from "./NonUrgentToastContainer";
|
||||
|
@ -71,6 +70,7 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload
|
|||
import { IConfigOptions } from "../../IConfigOptions";
|
||||
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
|
||||
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
|
||||
import { PipContainer } from "./PipContainer";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
|
|
@ -25,6 +25,7 @@ import Spinner from "../views/elements/Spinner";
|
|||
import { Layout } from "../../settings/enums/Layout";
|
||||
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import Measured from "../views/elements/Measured";
|
||||
import Heading from "../views/typography/Heading";
|
||||
|
||||
interface IProps {
|
||||
onClose(): void;
|
||||
|
@ -90,8 +91,21 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
|
|||
narrow: this.state.narrow,
|
||||
}}
|
||||
>
|
||||
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
|
||||
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
|
||||
<BaseCard
|
||||
header={
|
||||
<Heading size="h4" className="mx_BaseCard_header_title_heading">
|
||||
{_t("Notifications")}
|
||||
</Heading>
|
||||
}
|
||||
/**
|
||||
* Need to rename this CSS class to something more generic
|
||||
* Will be done once all the panels are using a similar layout
|
||||
*/
|
||||
className="mx_ThreadPanel"
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
>
|
||||
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
|
||||
{content}
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
|
|
|
@ -16,9 +16,9 @@ limitations under the License.
|
|||
|
||||
import React, { createRef } from "react";
|
||||
|
||||
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
|
||||
import { lerp } from "../../../utils/AnimationUtils";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
import { lerp } from "../../utils/AnimationUtils";
|
||||
import { MarkedExecution } from "../../utils/MarkedExecution";
|
||||
|
||||
const PIP_VIEW_WIDTH = 336;
|
||||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
@ -47,7 +47,7 @@ interface IChildrenOptions {
|
|||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
children: CreatePipChildren;
|
||||
children: Array<CreatePipChildren>;
|
||||
draggable: boolean;
|
||||
onDoubleClick?: () => void;
|
||||
onMove?: () => void;
|
||||
|
@ -65,12 +65,20 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
|||
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
|
||||
private translationX = this.desiredTranslationX;
|
||||
private translationY = this.desiredTranslationY;
|
||||
private moving = false;
|
||||
private scheduledUpdate = new MarkedExecution(
|
||||
private mouseHeld = false;
|
||||
private scheduledUpdate: MarkedExecution = new MarkedExecution(
|
||||
() => this.animationCallback(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
|
||||
private _moving = false;
|
||||
public get moving(): boolean {
|
||||
return this._moving;
|
||||
}
|
||||
private set moving(value: boolean) {
|
||||
this._moving = value;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
document.addEventListener("mousemove", this.onMoving);
|
||||
document.addEventListener("mouseup", this.onEndMoving);
|
||||
|
@ -85,6 +93,10 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
|||
UIStore.instance.off(UI_EVENTS.Resize, this.onResize);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
if (prevProps.children !== this.props.children) this.snap(true);
|
||||
}
|
||||
|
||||
private animationCallback = () => {
|
||||
if (
|
||||
!this.moving &&
|
||||
|
@ -179,42 +191,68 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.moving = true;
|
||||
this.initX = event.pageX - this.desiredTranslationX;
|
||||
this.initY = event.pageY - this.desiredTranslationY;
|
||||
this.scheduledUpdate.mark();
|
||||
this.mouseHeld = true;
|
||||
};
|
||||
|
||||
private onMoving = (event: React.MouseEvent | MouseEvent) => {
|
||||
if (!this.moving) return;
|
||||
private onMoving = (event: MouseEvent) => {
|
||||
if (!this.mouseHeld) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.moving) {
|
||||
this.moving = true;
|
||||
this.initX = event.pageX - this.desiredTranslationX;
|
||||
this.initY = event.pageY - this.desiredTranslationY;
|
||||
this.scheduledUpdate.mark();
|
||||
}
|
||||
|
||||
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
|
||||
};
|
||||
|
||||
private onEndMoving = () => {
|
||||
this.moving = false;
|
||||
private onEndMoving = (event: MouseEvent) => {
|
||||
if (!this.mouseHeld) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseHeld = false;
|
||||
// Delaying this to the next event loop tick is necessary for click
|
||||
// event cancellation to work
|
||||
setImmediate(() => (this.moving = false));
|
||||
this.snap(true);
|
||||
};
|
||||
|
||||
private onClickCapture = (event: React.MouseEvent) => {
|
||||
// To prevent mouse up events during dragging from being double-counted
|
||||
// as clicks, we cancel clicks before they ever reach the target
|
||||
if (this.moving) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const style = {
|
||||
transform: `translateX(${this.translationX}px) translateY(${this.translationY}px)`,
|
||||
};
|
||||
|
||||
const children = this.props.children.map((create: CreatePipChildren) => {
|
||||
return create({
|
||||
onStartMoving: this.onStartMoving,
|
||||
onResize: this.onResize,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={this.props.className}
|
||||
style={style}
|
||||
ref={this.callViewWrapper}
|
||||
onClickCapture={this.onClickCapture}
|
||||
onDoubleClick={this.props.onDoubleClick}
|
||||
>
|
||||
{this.props.children({
|
||||
onStartMoving: this.onStartMoving,
|
||||
onResize: this.onResize,
|
||||
})}
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
|
@ -14,28 +14,22 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef, useContext } from "react";
|
||||
import React, { MutableRefObject, useContext, useRef } from "react";
|
||||
import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import LegacyCallView from "./LegacyCallView";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
|
||||
import PersistentApp from "../elements/PersistentApp";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import LegacyCallView from "../views/voip/LegacyCallView";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import PictureInPictureDragger, { CreatePipChildren } from "./PictureInPictureDragger";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import LegacyCallViewHeader from "./LegacyCallView/LegacyCallViewHeader";
|
||||
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../../stores/ActiveWidgetStore";
|
||||
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { CallStore } from "../../../stores/CallStore";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore";
|
||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext";
|
||||
import {
|
||||
useCurrentVoiceBroadcastPreRecording,
|
||||
useCurrentVoiceBroadcastRecording,
|
||||
|
@ -46,8 +40,9 @@ import {
|
|||
VoiceBroadcastRecording,
|
||||
VoiceBroadcastRecordingPip,
|
||||
VoiceBroadcastSmallPlaybackBody,
|
||||
} from "../../../voice-broadcast";
|
||||
import { useCurrentVoiceBroadcastPlayback } from "../../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback";
|
||||
} from "../../voice-broadcast";
|
||||
import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback";
|
||||
import { WidgetPip } from "../views/pips/WidgetPip";
|
||||
|
||||
const SHOW_CALL_IN_STATES = [
|
||||
CallState.Connected,
|
||||
|
@ -59,9 +54,10 @@ const SHOW_CALL_IN_STATES = [
|
|||
];
|
||||
|
||||
interface IProps {
|
||||
voiceBroadcastRecording?: Optional<VoiceBroadcastRecording>;
|
||||
voiceBroadcastPreRecording?: Optional<VoiceBroadcastPreRecording>;
|
||||
voiceBroadcastPlayback?: Optional<VoiceBroadcastPlayback>;
|
||||
voiceBroadcastRecording: Optional<VoiceBroadcastRecording>;
|
||||
voiceBroadcastPreRecording: Optional<VoiceBroadcastPreRecording>;
|
||||
voiceBroadcastPlayback: Optional<VoiceBroadcastPlayback>;
|
||||
movePersistedElement: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -78,20 +74,8 @@ interface IState {
|
|||
persistentWidgetId: string;
|
||||
persistentRoomId: string;
|
||||
showWidgetInPip: boolean;
|
||||
|
||||
moving: boolean;
|
||||
}
|
||||
|
||||
const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room | null, IApp | null] => {
|
||||
if (!widgetId) return [null, null];
|
||||
if (!roomId) return [null, null];
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const app = WidgetStore.instance.getApps(roomId).find((app) => app.id === widgetId);
|
||||
|
||||
return [room, app || null];
|
||||
};
|
||||
|
||||
// Splits a list of calls into one 'primary' one and a list
|
||||
// (which should be a single element) of other calls.
|
||||
// The primary will be the one not on hold, or an arbitrary one
|
||||
|
@ -128,16 +112,12 @@ function getPrimarySecondaryCallsForPip(roomId: Optional<string>): [MatrixCall |
|
|||
}
|
||||
|
||||
/**
|
||||
* PipView shows a small version of the LegacyCallView or a sticky widget hovering over the UI in 'picture-in-picture'
|
||||
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
|
||||
* PipContainer shows a small version of the LegacyCallView or a sticky widget hovering over the UI in
|
||||
* 'picture-in-picture' (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
|
||||
* and all widgets that are active but not shown in any other possible container.
|
||||
*/
|
||||
|
||||
class PipView extends React.Component<IProps, IState> {
|
||||
// The cast is not so great, but solves the typing issue for the moment.
|
||||
// Proper solution: use useRef (requires the component to be refactored to a functional component).
|
||||
private movePersistedElement = createRef<() => void>() as React.MutableRefObject<() => void>;
|
||||
|
||||
class PipContainerInner extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -146,7 +126,6 @@ class PipView extends React.Component<IProps, IState> {
|
|||
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId);
|
||||
|
||||
this.state = {
|
||||
moving: false,
|
||||
viewedRoomId: roomId || undefined,
|
||||
primaryCall: primaryCall || null,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
|
@ -168,7 +147,6 @@ class PipView extends React.Component<IProps, IState> {
|
|||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
|
||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
|
||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
|
||||
document.addEventListener("mouseup", this.onEndMoving.bind(this));
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -184,18 +162,9 @@ class PipView extends React.Component<IProps, IState> {
|
|||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
|
||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
|
||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
|
||||
document.removeEventListener("mouseup", this.onEndMoving.bind(this));
|
||||
}
|
||||
|
||||
private onStartMoving() {
|
||||
this.setState({ moving: true });
|
||||
}
|
||||
|
||||
private onEndMoving() {
|
||||
this.setState({ moving: false });
|
||||
}
|
||||
|
||||
private onMove = () => this.movePersistedElement.current?.();
|
||||
private onMove = () => this.props.movePersistedElement.current?.();
|
||||
|
||||
private onRoomViewStoreUpdate = () => {
|
||||
const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
|
@ -265,53 +234,6 @@ class PipView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onMaximize = (): void => {
|
||||
const widgetId = this.state.persistentWidgetId;
|
||||
const roomId = this.state.persistentRoomId;
|
||||
|
||||
if (this.state.showWidgetInPip && widgetId && roomId) {
|
||||
const [room, app] = getRoomAndAppForWidget(widgetId, roomId);
|
||||
|
||||
if (room && app) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: "video_fullscreen",
|
||||
fullscreen: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onPin = (): void => {
|
||||
if (!this.state.showWidgetInPip) return;
|
||||
|
||||
const [room, app] = getRoomAndAppForWidget(this.state.persistentWidgetId, this.state.persistentRoomId);
|
||||
|
||||
if (room && app) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
|
||||
}
|
||||
};
|
||||
|
||||
private onExpand = (): void => {
|
||||
const widgetId = this.state.persistentWidgetId;
|
||||
if (!widgetId || !this.state.showWidgetInPip) return;
|
||||
|
||||
dis.dispatch({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.state.persistentRoomId,
|
||||
});
|
||||
};
|
||||
|
||||
private onViewCall = (): void =>
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.state.persistentRoomId,
|
||||
view_call: true,
|
||||
metricsTrigger: undefined,
|
||||
});
|
||||
|
||||
// Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId
|
||||
public updateShowWidgetInPip(
|
||||
persistentWidgetId = this.state.persistentWidgetId,
|
||||
|
@ -373,24 +295,20 @@ class PipView extends React.Component<IProps, IState> {
|
|||
|
||||
public render() {
|
||||
const pipMode = true;
|
||||
let pipContent: CreatePipChildren | null = null;
|
||||
|
||||
if (this.props.voiceBroadcastPlayback) {
|
||||
pipContent = this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback);
|
||||
}
|
||||
|
||||
if (this.props.voiceBroadcastPreRecording) {
|
||||
pipContent = this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording);
|
||||
}
|
||||
let pipContent: Array<CreatePipChildren> = [];
|
||||
|
||||
if (this.props.voiceBroadcastRecording) {
|
||||
pipContent = this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording);
|
||||
pipContent = [this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording)];
|
||||
} else if (this.props.voiceBroadcastPreRecording) {
|
||||
pipContent = [this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording)];
|
||||
} else if (this.props.voiceBroadcastPlayback) {
|
||||
pipContent = [this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback)];
|
||||
}
|
||||
|
||||
if (this.state.primaryCall) {
|
||||
// get a ref to call inside the current scope
|
||||
const call = this.state.primaryCall;
|
||||
pipContent = ({ onStartMoving, onResize }) => (
|
||||
pipContent.push(({ onStartMoving, onResize }) => (
|
||||
<LegacyCallView
|
||||
onMouseDownOnHeader={onStartMoving}
|
||||
call={call}
|
||||
|
@ -398,44 +316,22 @@ class PipView extends React.Component<IProps, IState> {
|
|||
pipMode={pipMode}
|
||||
onResize={onResize}
|
||||
/>
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
if (this.state.showWidgetInPip) {
|
||||
const pipViewClasses = classNames({
|
||||
mx_LegacyCallView: true,
|
||||
mx_LegacyCallView_pip: pipMode,
|
||||
mx_LegacyCallView_large: !pipMode,
|
||||
});
|
||||
const roomId = this.state.persistentRoomId;
|
||||
const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!;
|
||||
const viewingCallRoom = this.state.viewedRoomId === roomId;
|
||||
const isCall = CallStore.instance.getActiveCall(roomId) !== null;
|
||||
|
||||
pipContent = ({ onStartMoving }) => (
|
||||
<div className={pipViewClasses}>
|
||||
<LegacyCallViewHeader
|
||||
onPipMouseDown={(event) => {
|
||||
onStartMoving?.(event);
|
||||
this.onStartMoving.bind(this)();
|
||||
}}
|
||||
pipMode={pipMode}
|
||||
callRooms={[roomForWidget]}
|
||||
onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined}
|
||||
onPin={!isCall && viewingCallRoom ? this.onPin : undefined}
|
||||
onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined}
|
||||
/>
|
||||
<PersistentApp
|
||||
persistentWidgetId={this.state.persistentWidgetId}
|
||||
persistentRoomId={roomId}
|
||||
pointerEvents={this.state.moving ? "none" : undefined}
|
||||
movePersistedElement={this.movePersistedElement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
pipContent.push(({ onStartMoving }) => (
|
||||
<WidgetPip
|
||||
widgetId={this.state.persistentWidgetId}
|
||||
room={MatrixClientPeg.get().getRoom(this.state.persistentRoomId)!}
|
||||
viewingRoom={this.state.viewedRoomId === this.state.persistentRoomId}
|
||||
onStartMoving={onStartMoving}
|
||||
movePersistedElement={this.props.movePersistedElement}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
if (!!pipContent) {
|
||||
if (pipContent.length) {
|
||||
return (
|
||||
<PictureInPictureDragger
|
||||
className="mx_LegacyCallPreview"
|
||||
|
@ -452,7 +348,7 @@ class PipView extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
const PipViewHOC: React.FC<IProps> = (props) => {
|
||||
export const PipContainer: React.FC = () => {
|
||||
const sdkContext = useContext(SDKContext);
|
||||
const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore;
|
||||
const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording(voiceBroadcastPreRecordingStore);
|
||||
|
@ -463,14 +359,14 @@ const PipViewHOC: React.FC<IProps> = (props) => {
|
|||
const voiceBroadcastPlaybacksStore = sdkContext.voiceBroadcastPlaybacksStore;
|
||||
const { currentVoiceBroadcastPlayback } = useCurrentVoiceBroadcastPlayback(voiceBroadcastPlaybacksStore);
|
||||
|
||||
const movePersistedElement = useRef<() => void>();
|
||||
|
||||
return (
|
||||
<PipView
|
||||
<PipContainerInner
|
||||
voiceBroadcastPlayback={currentVoiceBroadcastPlayback}
|
||||
voiceBroadcastPreRecording={currentVoiceBroadcastPreRecording}
|
||||
voiceBroadcastRecording={currentVoiceBroadcastRecording}
|
||||
{...props}
|
||||
movePersistedElement={movePersistedElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipViewHOC;
|
|
@ -19,6 +19,7 @@ import { ISearchResults } from "matrix-js-sdk/src/@types/search";
|
|||
import { IThreadBundledRelationship } from "matrix-js-sdk/src/models/event";
|
||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import { SearchScope } from "../views/rooms/SearchBar";
|
||||
|
@ -214,6 +215,8 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
|||
};
|
||||
|
||||
let lastRoomId: string;
|
||||
let mergedTimeline: MatrixEvent[] = [];
|
||||
let ourEventsIndexes: number[] = [];
|
||||
|
||||
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = results.results[i];
|
||||
|
@ -251,16 +254,54 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
|||
|
||||
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
|
||||
|
||||
// merging two successive search result if the query is present in both of them
|
||||
const currentTimeline = result.context.getTimeline();
|
||||
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
|
||||
|
||||
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
|
||||
// if this is the first searchResult we merge then add all values of the current searchResult
|
||||
if (mergedTimeline.length == 0) {
|
||||
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
|
||||
mergedTimeline.push(currentTimeline[j]);
|
||||
}
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
// merge the events of the next searchResult
|
||||
for (let j = 1; j < nextTimeline.length; j++) {
|
||||
mergedTimeline.push(nextTimeline[j]);
|
||||
}
|
||||
|
||||
// add the index of the matching event of the next searchResult
|
||||
ourEventsIndexes.push(
|
||||
ourEventsIndexes[ourEventsIndexes.length - 1] +
|
||||
results.results[i - 1].context.getOurEventIndex() +
|
||||
1,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mergedTimeline.length == 0) {
|
||||
mergedTimeline = result.context.getTimeline();
|
||||
ourEventsIndexes = [];
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
ret.push(
|
||||
<SearchResultTile
|
||||
key={mxEv.getId()}
|
||||
searchResult={result}
|
||||
searchHighlights={highlights}
|
||||
timeline={mergedTimeline}
|
||||
ourEventsIndexes={ourEventsIndexes}
|
||||
searchHighlights={highlights ?? []}
|
||||
resultLink={resultLink}
|
||||
permalinkCreator={permalinkCreator}
|
||||
onHeightChanged={onHeightChanged}
|
||||
/>,
|
||||
);
|
||||
|
||||
ourEventsIndexes = [];
|
||||
mergedTimeline = [];
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1637,7 +1637,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
let i = events.length - 1;
|
||||
let userMembership = "leave";
|
||||
for (; i >= 0; i--) {
|
||||
const timeline = room.getTimelineForEvent(events[i].getId());
|
||||
const timeline = this.props.timelineSet.getTimelineForEvent(events[i].getId()!);
|
||||
if (!timeline) {
|
||||
// Somehow, it seems to be possible for live events to not have
|
||||
// a timeline, even though that should not happen. :(
|
||||
|
|
|
@ -50,11 +50,8 @@ import UserIdentifierCustomisations from "../../customisations/UserIdentifier";
|
|||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
|
||||
import { Icon as LiveIcon } from "../../../res/img/element-icons/live.svg";
|
||||
import {
|
||||
VoiceBroadcastRecording,
|
||||
VoiceBroadcastRecordingsStore,
|
||||
VoiceBroadcastRecordingsStoreEvent,
|
||||
} from "../../voice-broadcast";
|
||||
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
|
||||
interface IProps {
|
||||
isPanelCollapsed: boolean;
|
||||
|
@ -87,21 +84,24 @@ const below = (rect: PartialDOMRect) => {
|
|||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
public static contextType = SDKContext;
|
||||
public context!: React.ContextType<typeof SDKContext>;
|
||||
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private readonly dndWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
private voiceBroadcastRecordingStore = VoiceBroadcastRecordingsStore.instance();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.context = context;
|
||||
this.state = {
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
||||
showLiveAvatarAddon: this.voiceBroadcastRecordingStore.hasCurrent(),
|
||||
showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(),
|
||||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
|
@ -119,7 +119,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.voiceBroadcastRecordingStore.on(
|
||||
this.context.voiceBroadcastRecordingsStore.on(
|
||||
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
|
||||
this.onCurrentVoiceBroadcastRecordingChanged,
|
||||
);
|
||||
|
@ -133,7 +133,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
this.voiceBroadcastRecordingStore.off(
|
||||
this.context.voiceBroadcastRecordingsStore.off(
|
||||
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
|
||||
this.onCurrentVoiceBroadcastRecordingChanged,
|
||||
);
|
||||
|
|
|
@ -22,7 +22,7 @@ import EmailField from "../../../views/auth/EmailField";
|
|||
import { ErrorMessage } from "../../ErrorMessage";
|
||||
import Spinner from "../../../views/elements/Spinner";
|
||||
import Field from "../../../views/elements/Field";
|
||||
import AccessibleButton from "../../../views/elements/AccessibleButton";
|
||||
import AccessibleButton, { ButtonEvent } from "../../../views/elements/AccessibleButton";
|
||||
|
||||
interface EnterEmailProps {
|
||||
email: string;
|
||||
|
@ -94,7 +94,10 @@ export const EnterEmail: React.FC<EnterEmailProps> = ({
|
|||
className="mx_AuthBody_sign-in-instead-button"
|
||||
element="button"
|
||||
kind="link"
|
||||
onClick={onLoginClick}
|
||||
onClick={(e: ButtonEvent) => {
|
||||
e.preventDefault();
|
||||
onLoginClick();
|
||||
}}
|
||||
>
|
||||
{_t("Sign in instead")}
|
||||
</AccessibleButton>
|
||||
|
|
|
@ -60,7 +60,7 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
percentage: 0,
|
||||
percentage: percentageOf(this.props.playback.timeSeconds, 0, this.props.playback.durationSeconds),
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
|
|
|
@ -29,6 +29,7 @@ import * as Avatar from "../../../Avatar";
|
|||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
import { LocalRoom } from "../../../models/LocalRoom";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
|
||||
// Room may be left unset here, but if it is,
|
||||
|
@ -117,13 +118,26 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
};
|
||||
|
||||
private get roomIdName(): string | undefined {
|
||||
const room = this.props.room;
|
||||
|
||||
if (room) {
|
||||
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
if (dmMapUserId) return dmMapUserId;
|
||||
|
||||
if (room instanceof LocalRoom && room.targets.length === 1) {
|
||||
return room.targets[0].userId;
|
||||
}
|
||||
}
|
||||
|
||||
return this.props.room?.roomId || this.props.oobData?.roomId;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
|
||||
|
||||
const roomName = room?.name ?? oobData.name;
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
const idName = room ? DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId : oobData.roomId;
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
|
@ -132,7 +146,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
mx_RoomAvatar_isSpaceRoom: (room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space,
|
||||
})}
|
||||
name={roomName}
|
||||
idName={idName}
|
||||
idName={this.roomIdName}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
|
|
|
@ -45,10 +45,12 @@ const BeaconMarker: React.FC<Props> = ({ map, beacon, tooltip }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const geoUri = latestLocationState?.uri;
|
||||
const geoUri = latestLocationState.uri || "";
|
||||
|
||||
const markerRoomMember =
|
||||
beacon.beaconInfo.assetType === LocationAssetType.Self ? room.getMember(beacon.beaconInfoOwner) : undefined;
|
||||
const assetTypeIsSelf = beacon.beaconInfo?.assetType === LocationAssetType.Self;
|
||||
const _member = room?.getMember(beacon.beaconInfoOwner);
|
||||
|
||||
const markerRoomMember = assetTypeIsSelf && _member ? _member : undefined;
|
||||
|
||||
return (
|
||||
<SmartMarker
|
||||
|
|
|
@ -103,7 +103,7 @@ const RoomLiveShareWarningInner: React.FC<RoomLiveShareWarningInnerProps> = ({ l
|
|||
|
||||
<AccessibleButton
|
||||
className="mx_RoomLiveShareWarning_stopButton"
|
||||
data-test-id="room-live-share-primary-button"
|
||||
data-testid="room-live-share-primary-button"
|
||||
onClick={stopPropagationWrapper(onButtonClick)}
|
||||
kind="danger"
|
||||
element="button"
|
||||
|
@ -113,7 +113,7 @@ const RoomLiveShareWarningInner: React.FC<RoomLiveShareWarningInnerProps> = ({ l
|
|||
</AccessibleButton>
|
||||
{hasLocationPublishError && (
|
||||
<AccessibleButton
|
||||
data-test-id="room-live-share-wire-error-close-button"
|
||||
data-testid="room-live-share-wire-error-close-button"
|
||||
title={_t("Stop and close")}
|
||||
element="button"
|
||||
className="mx_RoomLiveShareWarning_closeButton"
|
||||
|
|
|
@ -23,11 +23,14 @@ import RoomListActions from "../../../actions/RoomListActions";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { clearRoomNotification } from "../../../utils/notifications";
|
||||
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuCheckbox,
|
||||
|
@ -36,7 +39,7 @@ import IconizedContextMenu, {
|
|||
} from "../context_menus/IconizedContextMenu";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
export interface RoomGeneralContextMenuProps extends IContextMenuProps {
|
||||
room: Room;
|
||||
onPostFavoriteClick?: (event: ButtonEvent) => void;
|
||||
onPostLowPriorityClick?: (event: ButtonEvent) => void;
|
||||
|
@ -58,7 +61,7 @@ export const RoomGeneralContextMenu = ({
|
|||
onPostLeaveClick,
|
||||
onPostForgetClick,
|
||||
...props
|
||||
}: IProps) => {
|
||||
}: RoomGeneralContextMenuProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
|
||||
RoomListStore.instance.getTagsForRoom(room),
|
||||
|
@ -115,8 +118,8 @@ export const RoomGeneralContextMenu = ({
|
|||
/>
|
||||
);
|
||||
|
||||
let inviteOption: JSX.Element;
|
||||
if (room.canInvite(cli.getUserId()) && !isDm) {
|
||||
let inviteOption: JSX.Element | null = null;
|
||||
if (room.canInvite(cli.getUserId()!) && !isDm) {
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
onClick={wrapHandler(
|
||||
|
@ -133,7 +136,7 @@ export const RoomGeneralContextMenu = ({
|
|||
);
|
||||
}
|
||||
|
||||
let copyLinkOption: JSX.Element;
|
||||
let copyLinkOption: JSX.Element | null = null;
|
||||
if (!isDm) {
|
||||
copyLinkOption = (
|
||||
<IconizedContextMenuOption
|
||||
|
@ -201,17 +204,34 @@ export const RoomGeneralContextMenu = ({
|
|||
);
|
||||
}
|
||||
|
||||
const { color } = useUnreadNotifications(room);
|
||||
const markAsReadOption: JSX.Element | null =
|
||||
color > NotificationColor.None ? (
|
||||
<IconizedContextMenuCheckbox
|
||||
onClick={() => {
|
||||
clearRoomNotification(room, cli);
|
||||
onFinished?.();
|
||||
}}
|
||||
active={false}
|
||||
label={_t("Mark as read")}
|
||||
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomGeneralContextMenu" compact>
|
||||
{!roomTags.includes(DefaultTagID.Archived) && (
|
||||
<IconizedContextMenuOptionList>
|
||||
{favoriteOption}
|
||||
{lowPriorityOption}
|
||||
{inviteOption}
|
||||
{copyLinkOption}
|
||||
{settingsOption}
|
||||
</IconizedContextMenuOptionList>
|
||||
)}
|
||||
<IconizedContextMenuOptionList>
|
||||
{markAsReadOption}
|
||||
{!roomTags.includes(DefaultTagID.Archived) && (
|
||||
<>
|
||||
{favoriteOption}
|
||||
{lowPriorityOption}
|
||||
{inviteOption}
|
||||
{copyLinkOption}
|
||||
{settingsOption}
|
||||
</>
|
||||
)}
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>{leaveOption}</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>
|
||||
);
|
||||
|
|
|
@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
|
||||
import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import Modal from "../../../Modal";
|
||||
import { isVoiceBroadcastStartedEvent } from "../../../voice-broadcast/utils/isVoiceBroadcastStartedEvent";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
import TextInputDialog from "./TextInputDialog";
|
||||
|
||||
|
@ -55,6 +57,14 @@ export function createRedactEventDialog({
|
|||
mxEvent: MatrixEvent;
|
||||
onCloseDialog?: () => void;
|
||||
}) {
|
||||
const eventId = mxEvent.getId();
|
||||
|
||||
if (!eventId) throw new Error("cannot redact event without ID");
|
||||
|
||||
const roomId = mxEvent.getRoomId();
|
||||
|
||||
if (!roomId) throw new Error(`cannot redact event ${mxEvent.getId()} without room ID`);
|
||||
|
||||
Modal.createDialog(
|
||||
ConfirmRedactDialog,
|
||||
{
|
||||
|
@ -62,10 +72,27 @@ export function createRedactEventDialog({
|
|||
if (!proceed) return;
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const withRelations: { with_relations?: RelationType[] } = {};
|
||||
|
||||
// redact related events if this is a voice broadcast started event and
|
||||
// server has support for relation based redactions
|
||||
if (isVoiceBroadcastStartedEvent(mxEvent)) {
|
||||
const relationBasedRedactionsSupport = cli.canSupport.get(Feature.RelationBasedRedactions);
|
||||
if (
|
||||
relationBasedRedactionsSupport &&
|
||||
relationBasedRedactionsSupport !== ServerSupport.Unsupported
|
||||
) {
|
||||
withRelations.with_relations = [RelationType.Reference];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
onCloseDialog?.();
|
||||
await cli.redactEvent(mxEvent.getRoomId(), mxEvent.getId(), undefined, reason ? { reason } : {});
|
||||
} catch (e) {
|
||||
await cli.redactEvent(roomId, eventId, undefined, {
|
||||
...(reason ? { reason } : {}),
|
||||
...withRelations,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const code = e.errcode || e.statusCode;
|
||||
// only show the dialog if failing for something other than a network error
|
||||
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
|
||||
|
|
|
@ -31,6 +31,7 @@ import { AccountDataExplorer, RoomAccountDataExplorer } from "./devtools/Account
|
|||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import ServerInfo from "./devtools/ServerInfo";
|
||||
import { Features } from "../../../settings/Settings";
|
||||
|
||||
enum Category {
|
||||
Room,
|
||||
|
@ -105,6 +106,7 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, onFinished }) => {
|
|||
<SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} />
|
||||
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
|
||||
<SettingsFlag name={Features.VoiceBroadcastForceSmallChunks} level={SettingLevel.DEVICE} />
|
||||
</div>
|
||||
</BaseTool>
|
||||
);
|
||||
|
|
|
@ -85,7 +85,7 @@ interface IProps {
|
|||
widgetPageTitle?: string;
|
||||
showLayoutButtons?: boolean;
|
||||
// Handle to manually notify the PersistedElement that it needs to move
|
||||
movePersistedElement?: MutableRefObject<() => void>;
|
||||
movePersistedElement?: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React, { MutableRefObject } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
@ -58,7 +57,7 @@ interface IProps {
|
|||
style?: React.StyleHTMLAttributes<HTMLDivElement>;
|
||||
|
||||
// Handle to manually notify this PersistedElement that it needs to move
|
||||
moveRef?: MutableRefObject<() => void>;
|
||||
moveRef?: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -177,24 +176,20 @@ export default class PersistedElement extends React.Component<IProps> {
|
|||
child.style.display = visible ? "block" : "none";
|
||||
}
|
||||
|
||||
private updateChildPosition = throttle(
|
||||
(child: HTMLDivElement, parent: HTMLDivElement): void => {
|
||||
if (!child || !parent) return;
|
||||
private updateChildPosition(child: HTMLDivElement, parent: HTMLDivElement): void {
|
||||
if (!child || !parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
Object.assign(child.style, {
|
||||
zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex,
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
transform: `translateX(${parentRect.left}px) translateY(${parentRect.top}px)`,
|
||||
width: parentRect.width + "px",
|
||||
height: parentRect.height + "px",
|
||||
});
|
||||
},
|
||||
16,
|
||||
{ trailing: true, leading: true },
|
||||
);
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
Object.assign(child.style, {
|
||||
zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex,
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
transform: `translateX(${parentRect.left}px) translateY(${parentRect.top}px)`,
|
||||
width: parentRect.width + "px",
|
||||
height: parentRect.height + "px",
|
||||
});
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return <div ref={this.collectChildContainer} />;
|
||||
|
|
|
@ -27,7 +27,7 @@ interface IProps {
|
|||
persistentWidgetId: string;
|
||||
persistentRoomId: string;
|
||||
pointerEvents?: string;
|
||||
movePersistedElement: MutableRefObject<() => void>;
|
||||
movePersistedElement: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
export default class PersistentApp extends React.Component<IProps> {
|
||||
|
|
|
@ -138,7 +138,7 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
|
|||
const pollStart = PollStartEvent.from(
|
||||
this.state.question.trim(),
|
||||
this.state.options.map((a) => a.trim()).filter((a) => !!a),
|
||||
this.state.kind,
|
||||
this.state.kind.name,
|
||||
).serialize();
|
||||
|
||||
if (!this.props.editingMxEvent) {
|
||||
|
|
|
@ -66,28 +66,30 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
|
|||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<div className="mx_CallEvent_infoRows">
|
||||
<span className="mx_CallEvent_title">
|
||||
{_t("%(name)s started a video call", { name: senderName })}
|
||||
</span>
|
||||
<LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("Video call")}
|
||||
active={false}
|
||||
participantCount={participatingMembers.length}
|
||||
/>
|
||||
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
|
||||
<div className="mx_CallEvent_columns">
|
||||
<div className="mx_CallEvent_details">
|
||||
<span className="mx_CallEvent_title">
|
||||
{_t("%(name)s started a video call", { name: senderName })}
|
||||
</span>
|
||||
<LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("Video call")}
|
||||
active={false}
|
||||
participantCount={participatingMembers.length}
|
||||
/>
|
||||
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
|
||||
</div>
|
||||
{call && <GroupCallDuration groupCall={call.groupCall} />}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallEvent_button"
|
||||
kind={buttonKind}
|
||||
disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
|
||||
onClick={onButtonClick}
|
||||
tooltip={buttonDisabledTooltip}
|
||||
>
|
||||
{buttonText}
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{call && <GroupCallDuration groupCall={call.groupCall} />}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallEvent_button"
|
||||
kind={buttonKind}
|
||||
disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
|
||||
onClick={onButtonClick}
|
||||
tooltip={buttonDisabledTooltip}
|
||||
>
|
||||
{buttonText}
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -164,15 +166,17 @@ export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
|
|||
const call = useCall(mxEvent.getRoomId()!);
|
||||
const latestEvent = client
|
||||
.getRoom(mxEvent.getRoomId())!
|
||||
.currentState.getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!);
|
||||
.currentState.getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!)!;
|
||||
|
||||
if ("m.terminated" in latestEvent.getContent()) {
|
||||
// The call is terminated
|
||||
return (
|
||||
<div className="mx_CallEvent_wrapper" ref={ref}>
|
||||
<div className="mx_CallEvent mx_CallEvent_inactive">
|
||||
<span className="mx_CallEvent_title">{_t("Video call ended")}</span>
|
||||
<CallDuration delta={latestEvent.getTs() - mxEvent.getTs()} />
|
||||
<div className="mx_CallEvent_columns">
|
||||
<span className="mx_CallEvent_title">{_t("Video call ended")}</span>
|
||||
<CallDuration delta={latestEvent.getTs() - mxEvent.getTs()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -59,6 +59,7 @@ import { Action } from "../../../dispatcher/actions";
|
|||
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import useFavouriteMessages from "../../../hooks/useFavouriteMessages";
|
||||
import { GetRelationsForEvent } from "../rooms/EventTile";
|
||||
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types";
|
||||
|
||||
interface IOptionsButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -394,7 +395,8 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
* until cross-platform support
|
||||
* (PSF-1041)
|
||||
*/
|
||||
!M_BEACON_INFO.matches(this.props.mxEvent.getType());
|
||||
!M_BEACON_INFO.matches(this.props.mxEvent.getType()) &&
|
||||
!(this.props.mxEvent.getType() === VoiceBroadcastInfoEventType);
|
||||
|
||||
return inNotThreadTimeline && isAllowedMessageType;
|
||||
}
|
||||
|
|
140
src/components/views/pips/WidgetPip.tsx
Normal file
140
src/components/views/pips/WidgetPip.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, MutableRefObject, useCallback, useMemo } from "react";
|
||||
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import PersistentApp from "../elements/PersistentApp";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useCallForWidget } from "../../../hooks/useCall";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import { Icon as BackIcon } from "../../../../res/img/element-icons/back.svg";
|
||||
import { Icon as HangupIcon } from "../../../../res/img/element-icons/call/hangup.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { WidgetType } from "../../../widgets/WidgetType";
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
interface Props {
|
||||
widgetId: string;
|
||||
room: Room;
|
||||
viewingRoom: boolean;
|
||||
onStartMoving: (e: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
movePersistedElement: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture-in-picture view for a widget. Additional controls are shown if the
|
||||
* widget is a call of some sort.
|
||||
*/
|
||||
export const WidgetPip: FC<Props> = ({ widgetId, room, viewingRoom, onStartMoving, movePersistedElement }) => {
|
||||
const widget = useMemo(
|
||||
() => WidgetStore.instance.getApps(room.roomId).find((app) => app.id === widgetId)!,
|
||||
[room, widgetId],
|
||||
);
|
||||
|
||||
const roomName = useTypedEventEmitterState(
|
||||
room,
|
||||
RoomEvent.Name,
|
||||
useCallback(() => room.name, [room]),
|
||||
);
|
||||
|
||||
const call = useCallForWidget(widgetId, room.roomId);
|
||||
|
||||
const onBackClick = useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (call !== null) {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
view_call: true,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
} else if (viewingRoom) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Center);
|
||||
} else {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
}
|
||||
},
|
||||
[room, call, widget, viewingRoom],
|
||||
);
|
||||
|
||||
const onLeaveClick = useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (call !== null) {
|
||||
call.disconnect().catch((e) => console.error("Failed to leave call", e));
|
||||
} else {
|
||||
// Assumed to be a Jitsi widget
|
||||
WidgetMessagingStore.instance
|
||||
.getMessagingForUid(WidgetUtils.getWidgetUid(widget))
|
||||
?.transport.send(ElementWidgetActions.HangupCall, {})
|
||||
.catch((e) => console.error("Failed to leave Jitsi", e));
|
||||
}
|
||||
},
|
||||
[call, widget],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_WidgetPip" onMouseDown={onStartMoving} onClick={onBackClick}>
|
||||
<Toolbar className="mx_WidgetPip_header">
|
||||
<RovingAccessibleButton
|
||||
onClick={onBackClick}
|
||||
className="mx_WidgetPip_backButton"
|
||||
aria-label={_t("Back")}
|
||||
>
|
||||
<BackIcon className="mx_Icon mx_Icon_16" />
|
||||
{roomName}
|
||||
</RovingAccessibleButton>
|
||||
</Toolbar>
|
||||
<PersistentApp
|
||||
persistentWidgetId={widgetId}
|
||||
persistentRoomId={room.roomId}
|
||||
pointerEvents="none"
|
||||
movePersistedElement={movePersistedElement}
|
||||
/>
|
||||
{(call !== null || WidgetType.JITSI.matches(widget.type)) && (
|
||||
<Toolbar className="mx_WidgetPip_footer">
|
||||
<RovingAccessibleTooltipButton
|
||||
onClick={onLeaveClick}
|
||||
tooltip={_t("Leave")}
|
||||
aria-label={_t("Leave")}
|
||||
alignment={Alignment.Top}
|
||||
>
|
||||
<HangupIcon className="mx_Icon mx_Icon_24" />
|
||||
</RovingAccessibleTooltipButton>
|
||||
</Toolbar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -29,8 +29,6 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto";
|
|||
import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
|
||||
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
|
||||
|
||||
import { Icon as LinkIcon } from "../../../../res/img/element-icons/link.svg";
|
||||
import { Icon as ViewInRoomIcon } from "../../../../res/img/element-icons/view-in-room.svg";
|
||||
import ReplyChain from "../elements/ReplyChain";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
@ -63,8 +61,6 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
|
||||
import { ThreadNotificationState } from "../../../stores/notifications/ThreadNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
|
@ -85,6 +81,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa
|
|||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { ElementCall } from "../../../models/Call";
|
||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
|
@ -325,7 +322,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
// events and pretty much anything that can't be sent by the composer as a message. For
|
||||
// those we rely on local echo giving the impression of things changing, and expect them
|
||||
// to be quick.
|
||||
const simpleSendableEvents = [EventType.Sticker, EventType.RoomMessage, EventType.RoomMessageEncrypted];
|
||||
const simpleSendableEvents = [
|
||||
EventType.Sticker,
|
||||
EventType.RoomMessage,
|
||||
EventType.RoomMessageEncrypted,
|
||||
EventType.PollStart,
|
||||
];
|
||||
if (!simpleSendableEvents.includes(this.props.mxEvent.getType() as EventType)) return false;
|
||||
|
||||
// Default case
|
||||
|
@ -972,6 +974,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
isContinuation = false;
|
||||
}
|
||||
|
||||
const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification;
|
||||
|
||||
const isEditing = !!this.props.editState;
|
||||
const classes = classNames({
|
||||
mx_EventTile_bubbleContainer: isBubbleMessage,
|
||||
|
@ -996,7 +1000,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
mx_EventTile_bad: isEncryptionFailure,
|
||||
mx_EventTile_emote: msgtype === MsgType.Emote,
|
||||
mx_EventTile_noSender: this.props.hideSender,
|
||||
mx_EventTile_clamp: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
||||
mx_EventTile_clamp:
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification,
|
||||
mx_EventTile_noBubble: noBubbleEvent,
|
||||
});
|
||||
|
||||
|
@ -1012,12 +1017,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
// Local echos have a send "status".
|
||||
const scrollToken = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId();
|
||||
|
||||
let avatar: JSX.Element;
|
||||
let sender: JSX.Element;
|
||||
let avatar: JSX.Element | null = null;
|
||||
let sender: JSX.Element | null = null;
|
||||
let avatarSize: number;
|
||||
let needsSenderProfile: boolean;
|
||||
|
||||
if (this.context.timelineRenderingType === TimelineRenderingType.Notification) {
|
||||
if (isRenderingNotification) {
|
||||
avatarSize = 24;
|
||||
needsSenderProfile = true;
|
||||
} else if (isInfoMessage) {
|
||||
|
@ -1061,7 +1066,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
member = this.props.mxEvent.sender;
|
||||
}
|
||||
// In the ThreadsList view we use the entire EventTile as a click target to open the thread instead
|
||||
const viewUserOnClick = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList;
|
||||
const viewUserOnClick = ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(
|
||||
this.context.timelineRenderingType,
|
||||
);
|
||||
avatar = (
|
||||
<div className="mx_EventTile_avatar">
|
||||
<MemberAvatar
|
||||
|
@ -1202,57 +1209,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.get().getUserId();
|
||||
|
||||
switch (this.context.timelineRenderingType) {
|
||||
case TimelineRenderingType.Notification: {
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
return React.createElement(
|
||||
this.props.as || "li",
|
||||
{
|
||||
"className": classes,
|
||||
"aria-live": ariaLive,
|
||||
"aria-atomic": true,
|
||||
"data-scroll-tokens": scrollToken,
|
||||
},
|
||||
[
|
||||
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
|
||||
<RoomAvatar room={room} width={28} height={28} />
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{room ? room.name : ""}
|
||||
</a>
|
||||
</div>,
|
||||
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
|
||||
{avatar}
|
||||
<a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
onContextMenu={this.onTimestampContextMenu}
|
||||
>
|
||||
{sender}
|
||||
{timestamp}
|
||||
</a>
|
||||
</div>,
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{renderTile(
|
||||
TimelineRenderingType.Notification,
|
||||
{
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
onHeightChanged: this.props.onHeightChanged,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
</div>,
|
||||
],
|
||||
);
|
||||
}
|
||||
case TimelineRenderingType.Thread: {
|
||||
return React.createElement(
|
||||
this.props.as || "li",
|
||||
|
@ -1289,8 +1245,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
onHeightChanged: this.props.onHeightChanged,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
onHeightChanged: () => this.props.onHeightChanged,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
|
@ -1304,6 +1260,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
],
|
||||
);
|
||||
}
|
||||
case TimelineRenderingType.Notification:
|
||||
case TimelineRenderingType.ThreadsList: {
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
|
@ -1326,20 +1283,48 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onClick": (ev: MouseEvent) => {
|
||||
dis.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: this.props.mxEvent,
|
||||
push: true,
|
||||
});
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const index = Array.from(target.parentElement.children).indexOf(target);
|
||||
PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index);
|
||||
let index = -1;
|
||||
if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target);
|
||||
switch (this.context.timelineRenderingType) {
|
||||
case TimelineRenderingType.Notification:
|
||||
this.viewInRoom(ev);
|
||||
break;
|
||||
case TimelineRenderingType.ThreadsList:
|
||||
dis.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: this.props.mxEvent,
|
||||
push: true,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index ?? -1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
<>
|
||||
{sender}
|
||||
{avatar}
|
||||
{timestamp}
|
||||
<div className="mx_EventTile_details">
|
||||
{sender}
|
||||
{isRenderingNotification && room ? (
|
||||
<span className="mx_EventTile_truncated">
|
||||
{" "}
|
||||
{_t(
|
||||
" in <strong>%(room)s</strong>",
|
||||
{ room: room.name },
|
||||
{ strong: (sub) => <strong>{sub}</strong> },
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{timestamp}
|
||||
</div>
|
||||
{isRenderingNotification && room ? (
|
||||
<div className="mx_EventTile_avatar">
|
||||
<RoomAvatar room={room} width={28} height={28} />
|
||||
</div>
|
||||
) : (
|
||||
avatar
|
||||
)}
|
||||
<div className={lineClasses} key="mx_EventTile_line">
|
||||
<div className="mx_EventTile_body">
|
||||
{this.props.mxEvent.isRedacted() ? (
|
||||
|
@ -1350,24 +1335,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||
</div>
|
||||
{this.renderThreadPanelSummary()}
|
||||
</div>
|
||||
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={this.viewInRoom}
|
||||
title={_t("View in room")}
|
||||
key="view_in_room"
|
||||
>
|
||||
<ViewInRoomIcon />
|
||||
</RovingAccessibleTooltipButton>
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={this.copyLinkToThread}
|
||||
title={_t("Copy link to thread")}
|
||||
key="copy_link_to_thread"
|
||||
>
|
||||
<LinkIcon />
|
||||
</RovingAccessibleTooltipButton>
|
||||
</Toolbar>
|
||||
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
||||
<EventTileThreadToolbar
|
||||
viewInRoom={this.viewInRoom}
|
||||
copyLinkToThread={this.copyLinkToThread}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msgOption}
|
||||
<UnreadNotificationBadge room={room} threadId={this.props.mxEvent.getId()} />
|
||||
</>,
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../../accessibility/Toolbar";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg";
|
||||
import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg";
|
||||
import { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
|
||||
export function EventTileThreadToolbar({
|
||||
viewInRoom,
|
||||
copyLinkToThread,
|
||||
}: {
|
||||
viewInRoom: (evt: ButtonEvent) => void;
|
||||
copyLinkToThread: (evt: ButtonEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={viewInRoom}
|
||||
title={_t("View in room")}
|
||||
key="view_in_room"
|
||||
>
|
||||
<ViewInRoomIcon />
|
||||
</RovingAccessibleTooltipButton>
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
onClick={copyLinkToThread}
|
||||
title={_t("Copy link to thread")}
|
||||
key="copy_link_to_thread"
|
||||
>
|
||||
<LinkIcon />
|
||||
</RovingAccessibleTooltipButton>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
|
@ -54,10 +54,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
|||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { Features } from "../../../settings/Settings";
|
||||
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||
import { VoiceBroadcastRecordingsStore } from "../../../voice-broadcast";
|
||||
import { SendWysiwygComposer, sendMessage } from "./wysiwyg_composer/";
|
||||
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
|
||||
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
|
||||
import { htmlToPlainText } from "../../../utils/room/htmlToPlaintext";
|
||||
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
|
||||
|
@ -334,7 +332,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.isWysiwygLabEnabled) {
|
||||
const { permalinkCreator, relation, replyToEvent } = this.props;
|
||||
sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
|
||||
await sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
|
||||
mxClient: this.props.mxClient,
|
||||
roomContext: this.context,
|
||||
permalinkCreator,
|
||||
|
@ -359,14 +357,19 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onRichTextToggle = () => {
|
||||
this.setState((state) => ({
|
||||
isRichTextEnabled: !state.isRichTextEnabled,
|
||||
initialComposerContent: !state.isRichTextEnabled
|
||||
? state.composerContent
|
||||
: // TODO when available use rust model plain text
|
||||
htmlToPlainText(state.composerContent),
|
||||
}));
|
||||
private onRichTextToggle = async () => {
|
||||
const { richToPlain, plainToRich } = await getConversionFunctions();
|
||||
|
||||
const { isRichTextEnabled, composerContent } = this.state;
|
||||
const convertedContent = isRichTextEnabled
|
||||
? await richToPlain(composerContent)
|
||||
: await plainToRich(composerContent);
|
||||
|
||||
this.setState({
|
||||
isRichTextEnabled: !isRichTextEnabled,
|
||||
composerContent: convertedContent,
|
||||
initialComposerContent: convertedContent,
|
||||
});
|
||||
};
|
||||
|
||||
private onVoiceStoreUpdate = () => {
|
||||
|
@ -604,7 +607,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
|||
this.props.room,
|
||||
MatrixClientPeg.get(),
|
||||
SdkContextClass.instance.voiceBroadcastPlaybacksStore,
|
||||
VoiceBroadcastRecordingsStore.instance(),
|
||||
SdkContextClass.instance.voiceBroadcastRecordingsStore,
|
||||
SdkContextClass.instance.voiceBroadcastPreRecordingStore,
|
||||
);
|
||||
this.toggleButtonMenu();
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
|
@ -30,12 +29,14 @@ import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "../../stru
|
|||
import { haveRendererForEvent } from "../../../events/EventTileFactory";
|
||||
|
||||
interface IProps {
|
||||
// a matrix-js-sdk SearchResult containing the details of this result
|
||||
searchResult: SearchResult;
|
||||
// a list of strings to be highlighted in the results
|
||||
searchHighlights?: string[];
|
||||
// href for the highlights in this result
|
||||
resultLink?: string;
|
||||
// timeline of the search result
|
||||
timeline: MatrixEvent[];
|
||||
// indexes of the matching events (not contextual ones)
|
||||
ourEventsIndexes: number[];
|
||||
onHeightChanged?: () => void;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
}
|
||||
|
@ -50,7 +51,7 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
public constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.buildLegacyCallEventGroupers(this.props.searchResult.context.getTimeline());
|
||||
this.buildLegacyCallEventGroupers(this.props.timeline);
|
||||
}
|
||||
|
||||
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
|
||||
|
@ -58,8 +59,8 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const result = this.props.searchResult;
|
||||
const resultEvent = result.context.getEvent();
|
||||
const timeline = this.props.timeline;
|
||||
const resultEvent = timeline[this.props.ourEventsIndexes[0]];
|
||||
const eventId = resultEvent.getId();
|
||||
|
||||
const ts1 = resultEvent.getTs();
|
||||
|
@ -69,11 +70,10 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
|
||||
const threadsEnabled = SettingsStore.getValue("feature_threadstable");
|
||||
|
||||
const timeline = result.context.getTimeline();
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
const mxEv = timeline[j];
|
||||
let highlights;
|
||||
const contextual = j != result.context.getOurEventIndex();
|
||||
const contextual = !this.props.ourEventsIndexes.includes(j);
|
||||
if (!contextual) {
|
||||
highlights = this.props.searchHighlights;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { SubSelection } from "./types";
|
||||
|
||||
export function getDefaultContextValue(): { selection: SubSelection } {
|
||||
return {
|
||||
selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposerContextState {
|
||||
selection: SubSelection;
|
||||
}
|
||||
|
||||
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());
|
||||
ComposerContext.displayName = "ComposerContext";
|
||||
|
||||
export function useComposerContext() {
|
||||
return useContext(ComposerContext);
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, lazy, Suspense } from "react";
|
||||
|
||||
// we need to import the types for TS, but do not import the sendMessage
|
||||
// function to avoid importing from "@matrix-org/matrix-wysiwyg"
|
||||
import { SendMessageParams } from "./utils/message";
|
||||
|
||||
const SendComposer = lazy(() => import("./SendWysiwygComposer"));
|
||||
const EditComposer = lazy(() => import("./EditWysiwygComposer"));
|
||||
|
||||
export const dynamicImportSendMessage = async (message: string, isHTML: boolean, params: SendMessageParams) => {
|
||||
const { sendMessage } = await import("./utils/message");
|
||||
|
||||
return sendMessage(message, isHTML, params);
|
||||
};
|
||||
|
||||
export const dynamicImportConversionFunctions = async () => {
|
||||
const { richToPlain, plainToRich } = await import("@matrix-org/matrix-wysiwyg");
|
||||
|
||||
return { richToPlain, plainToRich };
|
||||
};
|
||||
|
||||
export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>) {
|
||||
return (
|
||||
<Suspense fallback={<div />}>
|
||||
<SendComposer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicImportEditWysiwygComposer(props: ComponentProps<typeof EditComposer>) {
|
||||
return (
|
||||
<Suspense fallback={<div />}>
|
||||
<EditComposer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, RefObject } from "react";
|
||||
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
|
||||
|
@ -23,16 +23,19 @@ import { EditionButtons } from "./components/EditionButtons";
|
|||
import { useWysiwygEditActionHandler } from "./hooks/useWysiwygEditActionHandler";
|
||||
import { useEditing } from "./hooks/useEditing";
|
||||
import { useInitialContent } from "./hooks/useInitialContent";
|
||||
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
|
||||
import { ComposerFunctions } from "./types";
|
||||
|
||||
interface ContentProps {
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
composerFunctions: ComposerFunctions;
|
||||
}
|
||||
|
||||
const Content = forwardRef<HTMLElement, ContentProps>(function Content(
|
||||
{ disabled }: ContentProps,
|
||||
forwardRef: RefObject<HTMLElement>,
|
||||
{ disabled = false, composerFunctions }: ContentProps,
|
||||
forwardRef: ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
useWysiwygEditActionHandler(disabled, forwardRef);
|
||||
useWysiwygEditActionHandler(disabled, forwardRef as MutableRefObject<HTMLElement>, composerFunctions);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
@ -43,14 +46,20 @@ interface EditWysiwygComposerProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) {
|
||||
// Default needed for React.lazy
|
||||
export default function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) {
|
||||
const defaultContextValue = useRef(getDefaultContextValue());
|
||||
const initialContent = useInitialContent(editorStateTransfer);
|
||||
const isReady = !editorStateTransfer || initialContent !== undefined;
|
||||
|
||||
const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(editorStateTransfer, initialContent);
|
||||
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
isReady && (
|
||||
<ComposerContext.Provider value={defaultContextValue.current}>
|
||||
<WysiwygComposer
|
||||
className={classNames("mx_EditWysiwygComposer", className)}
|
||||
initialContent={initialContent}
|
||||
|
@ -58,9 +67,9 @@ export function EditWysiwygComposer({ editorStateTransfer, className, ...props }
|
|||
onSend={editMessage}
|
||||
{...props}
|
||||
>
|
||||
{(ref) => (
|
||||
{(ref, composerFunctions) => (
|
||||
<>
|
||||
<Content disabled={props.disabled} ref={ref} />
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
<EditionButtons
|
||||
onCancelClick={endEditing}
|
||||
onSaveClick={editMessage}
|
||||
|
@ -69,6 +78,6 @@ export function EditWysiwygComposer({ editorStateTransfer, className, ...props }
|
|||
</>
|
||||
)}
|
||||
</WysiwygComposer>
|
||||
)
|
||||
</ComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ForwardedRef, forwardRef, MutableRefObject } from "react";
|
||||
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
|
||||
|
||||
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
|
||||
import { WysiwygComposer } from "./components/WysiwygComposer";
|
||||
|
@ -24,6 +24,7 @@ import { E2EStatus } from "../../../../utils/ShieldUtils";
|
|||
import E2EIcon from "../E2EIcon";
|
||||
import { AboveLeftOf } from "../../../structures/ContextMenu";
|
||||
import { Emoji } from "./components/Emoji";
|
||||
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
|
||||
|
||||
interface ContentProps {
|
||||
disabled?: boolean;
|
||||
|
@ -49,26 +50,28 @@ interface SendWysiwygComposerProps {
|
|||
menuPosition: AboveLeftOf;
|
||||
}
|
||||
|
||||
export function SendWysiwygComposer({
|
||||
// Default needed for React.lazy
|
||||
export default function SendWysiwygComposer({
|
||||
isRichTextEnabled,
|
||||
e2eStatus,
|
||||
menuPosition,
|
||||
...props
|
||||
}: SendWysiwygComposerProps) {
|
||||
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
|
||||
const defaultContextValue = useRef(getDefaultContextValue());
|
||||
|
||||
return (
|
||||
<Composer
|
||||
className="mx_SendWysiwygComposer"
|
||||
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
|
||||
rightComponent={(selectPreviousSelection) => (
|
||||
<Emoji menuPosition={menuPosition} selectPreviousSelection={selectPreviousSelection} />
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{(ref, composerFunctions) => (
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
)}
|
||||
</Composer>
|
||||
<ComposerContext.Provider value={defaultContextValue.current}>
|
||||
<Composer
|
||||
className="mx_SendWysiwygComposer"
|
||||
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
|
||||
rightComponent={<Emoji menuPosition={menuPosition} />}
|
||||
{...props}
|
||||
>
|
||||
{(ref, composerFunctions) => (
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
)}
|
||||
</Composer>
|
||||
</ComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ interface EditorProps {
|
|||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
}
|
||||
|
||||
export const Editor = memo(
|
||||
|
@ -35,7 +35,7 @@ export const Editor = memo(
|
|||
ref,
|
||||
) {
|
||||
const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
|
||||
const { onFocus, onBlur, selectPreviousSelection, onInput } = useSelection();
|
||||
const { onFocus, onBlur, onInput } = useSelection();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -63,7 +63,7 @@ export const Editor = memo(
|
|||
onInput={onInput}
|
||||
/>
|
||||
</div>
|
||||
{rightComponent?.(selectPreviousSelection)}
|
||||
{rightComponent}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
|
|
@ -24,18 +24,16 @@ import { Action } from "../../../../../dispatcher/actions";
|
|||
import { useRoomContext } from "../../../../../contexts/RoomContext";
|
||||
|
||||
interface EmojiProps {
|
||||
selectPreviousSelection: () => void;
|
||||
menuPosition: AboveLeftOf;
|
||||
}
|
||||
|
||||
export function Emoji({ selectPreviousSelection, menuPosition }: EmojiProps) {
|
||||
export function Emoji({ menuPosition }: EmojiProps) {
|
||||
const roomContext = useRoomContext();
|
||||
|
||||
return (
|
||||
<EmojiButton
|
||||
menuPosition={menuPosition}
|
||||
addEmoji={(emoji) => {
|
||||
selectPreviousSelection();
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
text: emoji,
|
||||
|
|
|
@ -23,12 +23,15 @@ import { Icon as ItalicIcon } from "../../../../../../res/img/element-icons/room
|
|||
import { Icon as UnderlineIcon } from "../../../../../../res/img/element-icons/room/composer/underline.svg";
|
||||
import { Icon as StrikeThroughIcon } from "../../../../../../res/img/element-icons/room/composer/strikethrough.svg";
|
||||
import { Icon as InlineCodeIcon } from "../../../../../../res/img/element-icons/room/composer/inline_code.svg";
|
||||
import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/composer/link.svg";
|
||||
import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../../../elements/Tooltip";
|
||||
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
|
||||
import { KeyCombo } from "../../../../../KeyBindingsManager";
|
||||
import { _td } from "../../../../../languageHandler";
|
||||
import { ButtonEvent } from "../../../elements/AccessibleButton";
|
||||
import { openLinkModal } from "./LinkModal";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
|
@ -76,6 +79,8 @@ interface FormattingButtonsProps {
|
|||
}
|
||||
|
||||
export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps) {
|
||||
const composerContext = useComposerContext();
|
||||
|
||||
return (
|
||||
<div className="mx_FormattingButtons">
|
||||
<Button
|
||||
|
@ -112,6 +117,12 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
|
|||
onClick={() => composer.inlineCode()}
|
||||
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
|
||||
/>
|
||||
<Button
|
||||
isActive={actionStates.link === "reversed"}
|
||||
label={_td("Link")}
|
||||
onClick={() => openLinkModal(composer, composerContext)}
|
||||
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
|
||||
import React, { ChangeEvent, useState } from "react";
|
||||
|
||||
import { _td } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||
import Field from "../../../elements/Field";
|
||||
import { ComposerContextState } from "../ComposerContext";
|
||||
import { isSelectionEmpty, setSelection } from "../utils/selection";
|
||||
|
||||
export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) {
|
||||
const modal = Modal.createDialog(
|
||||
LinkModal,
|
||||
{ composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() },
|
||||
"mx_CompoundDialog",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmpty(text: string) {
|
||||
return text.length < 1;
|
||||
}
|
||||
|
||||
interface LinkModalProps {
|
||||
composer: FormattingFunctions;
|
||||
isTextEnabled: boolean;
|
||||
onClose: () => void;
|
||||
composerContext: ComposerContextState;
|
||||
}
|
||||
|
||||
export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) {
|
||||
const [fields, setFields] = useState({ text: "", link: "" });
|
||||
const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link);
|
||||
|
||||
return (
|
||||
<QuestionDialog
|
||||
className="mx_LinkModal"
|
||||
title={_td("Create a link")}
|
||||
button={_td("Save")}
|
||||
buttonDisabled={isSaveDisabled}
|
||||
hasCancelButton={true}
|
||||
onFinished={async (isClickOnSave: boolean) => {
|
||||
if (isClickOnSave) {
|
||||
await setSelection(composerContext.selection);
|
||||
composer.link(fields.link, isTextEnabled ? fields.text : undefined);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
description={
|
||||
<div className="mx_LinkModal_content">
|
||||
{isTextEnabled && (
|
||||
<Field
|
||||
autoFocus={true}
|
||||
label={_td("Text")}
|
||||
value={fields.text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setFields((fields) => ({ ...fields, text: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
autoFocus={!isTextEnabled}
|
||||
label={_td("Link")}
|
||||
value={fields.link}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setFields((fields) => ({ ...fields, link: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -33,7 +33,7 @@ interface PlainTextComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
children?: (ref: MutableRefObject<HTMLDivElement | null>, composerFunctions: ComposerFunctions) => ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ interface WysiwygComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
children?: (ref: MutableRefObject<HTMLDivElement | null>, wysiwyg: FormattingFunctions) => ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,11 +17,22 @@ limitations under the License.
|
|||
import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
||||
|
||||
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||
return target instanceof HTMLDivElement;
|
||||
}
|
||||
|
||||
// Hitting enter inside the editor inserts an editable div, initially containing a <br />
|
||||
// For correct display, first replace this pattern with a newline character and then remove divs
|
||||
// noting that they are used to delimit paragraphs
|
||||
function amendInnerHtml(text: string) {
|
||||
return text
|
||||
.replace(/<div><br><\/div>/g, "\n") // this is pressing enter then not typing
|
||||
.replace(/<div>/g, "\n") // this is from pressing enter, then typing inside the div
|
||||
.replace(/<\/div>/g, "");
|
||||
}
|
||||
|
||||
export function usePlainTextListeners(
|
||||
initialContent?: string,
|
||||
onChange?: (content: string) => void,
|
||||
|
@ -44,25 +55,39 @@ export function usePlainTextListeners(
|
|||
[onChange],
|
||||
);
|
||||
|
||||
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
const onInput = useCallback(
|
||||
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||
if (isDivElement(event.target)) {
|
||||
setText(event.target.innerHTML);
|
||||
// if enterShouldSend, we do not need to amend the html before setting text
|
||||
const newInnerHTML = enterShouldSend ? event.target.innerHTML : amendInnerHtml(event.target.innerHTML);
|
||||
setText(newInnerHTML);
|
||||
}
|
||||
},
|
||||
[setText],
|
||||
[setText, enterShouldSend],
|
||||
);
|
||||
|
||||
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
if (event.key === Key.ENTER) {
|
||||
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
|
||||
// if enter should send, send if the user is not pushing shift
|
||||
if (enterShouldSend && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
}
|
||||
|
||||
// if enter should not send, send only if the user is pushing ctrl/cmd
|
||||
if (!enterShouldSend && sendModifierIsPressed) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
}
|
||||
}
|
||||
},
|
||||
[isCtrlEnter, send],
|
||||
[enterShouldSend, send],
|
||||
);
|
||||
|
||||
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };
|
||||
|
|
|
@ -14,18 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MutableRefObject, useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import useFocus from "../../../../../hooks/useFocus";
|
||||
import { setSelection } from "../utils/selection";
|
||||
import { useComposerContext, ComposerContextState } from "../ComposerContext";
|
||||
|
||||
type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">;
|
||||
|
||||
function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) {
|
||||
function setSelectionContext(composerContext: ComposerContextState) {
|
||||
const selection = document.getSelection();
|
||||
|
||||
if (selection) {
|
||||
selectionRef.current = {
|
||||
composerContext.selection = {
|
||||
anchorNode: selection.anchorNode,
|
||||
anchorOffset: selection.anchorOffset,
|
||||
focusNode: selection.focusNode,
|
||||
|
@ -35,17 +33,12 @@ function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) {
|
|||
}
|
||||
|
||||
export function useSelection() {
|
||||
const selectionRef = useRef<SubSelection>({
|
||||
anchorNode: null,
|
||||
anchorOffset: 0,
|
||||
focusNode: null,
|
||||
focusOffset: 0,
|
||||
});
|
||||
const composerContext = useComposerContext();
|
||||
const [isFocused, focusProps] = useFocus();
|
||||
|
||||
useEffect(() => {
|
||||
function onSelectionChange() {
|
||||
setSelectionRef(selectionRef);
|
||||
setSelectionContext(composerContext);
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
|
@ -53,15 +46,11 @@ export function useSelection() {
|
|||
}
|
||||
|
||||
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
||||
}, [isFocused]);
|
||||
}, [isFocused, composerContext]);
|
||||
|
||||
const onInput = useCallback(() => {
|
||||
setSelectionRef(selectionRef);
|
||||
}, []);
|
||||
setSelectionContext(composerContext);
|
||||
}, [composerContext]);
|
||||
|
||||
const selectPreviousSelection = useCallback(() => {
|
||||
setSelection(selectionRef.current);
|
||||
}, []);
|
||||
|
||||
return { ...focusProps, selectPreviousSelection, onInput };
|
||||
return { ...focusProps, onInput };
|
||||
}
|
||||
|
|
|
@ -22,9 +22,18 @@ import { ActionPayload } from "../../../../../dispatcher/payloads";
|
|||
import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext";
|
||||
import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
||||
import { focusComposer } from "./utils";
|
||||
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { ComposerFunctions } from "../types";
|
||||
import { setSelection } from "../utils/selection";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
|
||||
export function useWysiwygEditActionHandler(disabled: boolean, composerElement: RefObject<HTMLElement>) {
|
||||
export function useWysiwygEditActionHandler(
|
||||
disabled: boolean,
|
||||
composerElement: RefObject<HTMLElement>,
|
||||
composerFunctions: ComposerFunctions,
|
||||
) {
|
||||
const roomContext = useRoomContext();
|
||||
const composerContext = useComposerContext();
|
||||
const timeoutId = useRef<number | null>(null);
|
||||
|
||||
const handler = useCallback(
|
||||
|
@ -39,9 +48,17 @@ export function useWysiwygEditActionHandler(disabled: boolean, composerElement:
|
|||
case Action.FocusEditMessageComposer:
|
||||
focusComposer(composerElement, context, roomContext, timeoutId);
|
||||
break;
|
||||
case Action.ComposerInsert:
|
||||
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
|
||||
if (payload.composerType !== ComposerType.Edit) break;
|
||||
|
||||
if (payload.text) {
|
||||
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[disabled, composerElement, timeoutId, roomContext],
|
||||
[disabled, composerElement, composerFunctions, timeoutId, roomContext, composerContext],
|
||||
);
|
||||
|
||||
useDispatcher(defaultDispatcher, handler);
|
||||
|
|
|
@ -24,6 +24,8 @@ import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
|||
import { focusComposer } from "./utils";
|
||||
import { ComposerFunctions } from "../types";
|
||||
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
import { setSelection } from "../utils/selection";
|
||||
|
||||
export function useWysiwygSendActionHandler(
|
||||
disabled: boolean,
|
||||
|
@ -31,6 +33,7 @@ export function useWysiwygSendActionHandler(
|
|||
composerFunctions: ComposerFunctions,
|
||||
) {
|
||||
const roomContext = useRoomContext();
|
||||
const composerContext = useComposerContext();
|
||||
const timeoutId = useRef<number | null>(null);
|
||||
|
||||
const handler = useCallback(
|
||||
|
@ -59,12 +62,12 @@ export function useWysiwygSendActionHandler(
|
|||
} else if (payload.event) {
|
||||
// TODO insert quote message - see SendMessageComposer
|
||||
} else if (payload.text) {
|
||||
composerFunctions.insertText(payload.text);
|
||||
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[disabled, composerElement, composerFunctions, timeoutId, roomContext],
|
||||
[disabled, composerElement, roomContext, composerFunctions, composerContext],
|
||||
);
|
||||
|
||||
useDispatcher(defaultDispatcher, handler);
|
||||
|
|
|
@ -14,6 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export { SendWysiwygComposer } from "./SendWysiwygComposer";
|
||||
export { EditWysiwygComposer } from "./EditWysiwygComposer";
|
||||
export { sendMessage } from "./utils/message";
|
||||
export {
|
||||
DynamicImportSendWysiwygComposer as SendWysiwygComposer,
|
||||
DynamicImportEditWysiwygComposer as EditWysiwygComposer,
|
||||
dynamicImportSendMessage as sendMessage,
|
||||
dynamicImportConversionFunctions as getConversionFunctions,
|
||||
} from "./DynamicImportWysiwygComposer";
|
||||
|
|
|
@ -18,3 +18,5 @@ export type ComposerFunctions = {
|
|||
clear: () => void;
|
||||
insertText: (text: string) => void;
|
||||
};
|
||||
|
||||
export type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">;
|
||||
|
|
|
@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { richToPlain, plainToRich } from "@matrix-org/matrix-wysiwyg";
|
||||
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||
import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";
|
||||
|
||||
// Merges favouring the given relation
|
||||
function attachRelation(content: IContent, relation?: IEventRelation): void {
|
||||
|
@ -62,7 +61,7 @@ interface CreateMessageContentParams {
|
|||
editedEvent?: MatrixEvent;
|
||||
}
|
||||
|
||||
export function createMessageContent(
|
||||
export async function createMessageContent(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{
|
||||
|
@ -72,7 +71,7 @@ export function createMessageContent(
|
|||
includeReplyLegacyFallback = true,
|
||||
editedEvent,
|
||||
}: CreateMessageContentParams,
|
||||
): IContent {
|
||||
): Promise<IContent> {
|
||||
// TODO emote ?
|
||||
|
||||
const isEditing = Boolean(editedEvent);
|
||||
|
@ -90,26 +89,22 @@ export function createMessageContent(
|
|||
|
||||
// const body = textSerialize(model);
|
||||
|
||||
// TODO remove this ugly hack for replace br tag
|
||||
const body = (isHTML && htmlToPlainText(message)) || message.replace(/<br>/g, "\n");
|
||||
// if we're editing rich text, the message content is pure html
|
||||
// BUT if we're not, the message content will be plain text
|
||||
const body = isHTML ? await richToPlain(message) : message;
|
||||
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
|
||||
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
|
||||
|
||||
const content: IContent = {
|
||||
// TODO emote
|
||||
msgtype: MsgType.Text,
|
||||
// TODO when available, use HTML --> Plain text conversion from wysiwyg rust model
|
||||
body: isEditing ? `${bodyPrefix} * ${body}` : body,
|
||||
};
|
||||
|
||||
// TODO markdown support
|
||||
|
||||
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
|
||||
const formattedBody = isHTML
|
||||
? message
|
||||
: isMarkdownEnabled
|
||||
? htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply })
|
||||
: null;
|
||||
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message) : null;
|
||||
|
||||
if (formattedBody) {
|
||||
content.format = "org.matrix.custom.html";
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
||||
import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||
|
||||
|
@ -34,7 +34,7 @@ import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
|||
import { createMessageContent } from "./createMessageContent";
|
||||
import { isContentModified } from "./isContentModified";
|
||||
|
||||
interface SendMessageParams {
|
||||
export interface SendMessageParams {
|
||||
mxClient: MatrixClient;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
|
@ -43,10 +43,18 @@ interface SendMessageParams {
|
|||
includeReplyLegacyFallback?: boolean;
|
||||
}
|
||||
|
||||
export function sendMessage(message: string, isHTML: boolean, { roomContext, mxClient, ...params }: SendMessageParams) {
|
||||
export async function sendMessage(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||
) {
|
||||
const { relation, replyToEvent } = params;
|
||||
const { room } = roomContext;
|
||||
const { roomId } = room;
|
||||
const roomId = room?.roomId;
|
||||
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posthogEvent: ComposerEvent = {
|
||||
eventName: "Composer",
|
||||
|
@ -63,7 +71,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
}*/
|
||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
||||
|
||||
let content: IContent;
|
||||
const content = await createMessageContent(message, isHTML, params);
|
||||
|
||||
// TODO slash comment
|
||||
|
||||
|
@ -71,10 +79,6 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
|
||||
// TODO quick reaction
|
||||
|
||||
if (!content) {
|
||||
content = createMessageContent(message, isHTML, params);
|
||||
}
|
||||
|
||||
// don't bother sending an empty message
|
||||
if (!content.body.trim()) {
|
||||
return;
|
||||
|
@ -84,7 +88,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
decorateStartSendingTime(content);
|
||||
}
|
||||
|
||||
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
|
||||
const threadId = relation?.event_id && relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
|
||||
|
||||
const prom = doMaybeLocalRoomAction(
|
||||
roomId,
|
||||
|
@ -139,7 +143,7 @@ interface EditMessageParams {
|
|||
editorStateTransfer: EditorStateTransfer;
|
||||
}
|
||||
|
||||
export function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
|
||||
export async function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
|
||||
const editedEvent = editorStateTransfer.getEvent();
|
||||
|
||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
|
||||
|
@ -156,7 +160,7 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
|
|||
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
|
||||
}*/
|
||||
const editContent = createMessageContent(html, true, { editedEvent });
|
||||
const editContent = await createMessageContent(html, true, { editedEvent });
|
||||
const newContent = editContent["m.new_content"];
|
||||
|
||||
const shouldSend = true;
|
||||
|
@ -174,10 +178,10 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
|
|||
|
||||
let response: Promise<ISendEventResponse> | undefined;
|
||||
|
||||
// If content is modified then send an updated event into the room
|
||||
if (isContentModified(newContent, editorStateTransfer)) {
|
||||
const roomId = editedEvent.getRoomId();
|
||||
const roomId = editedEvent.getRoomId();
|
||||
|
||||
// If content is modified then send an updated event into the room
|
||||
if (isContentModified(newContent, editorStateTransfer) && roomId) {
|
||||
// TODO Slash Commands
|
||||
|
||||
if (shouldSend) {
|
||||
|
|
|
@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">) {
|
||||
import { SubSelection } from "../types";
|
||||
|
||||
export function setSelection(selection: SubSelection) {
|
||||
if (selection.anchorNode && selection.focusNode) {
|
||||
const range = new Range();
|
||||
range.setStart(selection.anchorNode, selection.anchorOffset);
|
||||
|
@ -23,4 +25,12 @@ export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOf
|
|||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
// Waiting for the next loop to ensure that the selection is effective
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
export function isSelectionEmpty() {
|
||||
const selection = document.getSelection();
|
||||
return Boolean(selection?.isCollapsed);
|
||||
}
|
||||
|
|
|
@ -642,7 +642,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
|||
className="mx_UserNotifSettings_clearNotifsButton"
|
||||
data-testid="clear-notifications"
|
||||
>
|
||||
{_t("Clear notifications")}
|
||||
{_t("Mark all as read")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ async function checkIdentityServerUrl(u) {
|
|||
// XXX: duplicated logic from js-sdk but it's quite tied up in the validation logic in the
|
||||
// js-sdk so probably as easy to duplicate it than to separate it out so we can reuse it
|
||||
try {
|
||||
const response = await fetch(u + "/_matrix/identity/api/v1");
|
||||
const response = await fetch(u + "/_matrix/identity/v2");
|
||||
if (response.ok) {
|
||||
return null;
|
||||
} else if (response.status < 200 || response.status >= 300) {
|
||||
|
|
|
@ -34,6 +34,9 @@ interface Props {
|
|||
isLoading: boolean;
|
||||
isSigningOut: boolean;
|
||||
localNotificationSettings?: LocalNotificationSettings | undefined;
|
||||
// number of other sessions the user has
|
||||
// excludes current session
|
||||
otherSessionsCount: number;
|
||||
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
|
||||
onVerifyCurrentDevice: () => void;
|
||||
onSignOutCurrentDevice: () => void;
|
||||
|
@ -41,13 +44,17 @@ interface Props {
|
|||
saveDeviceName: (deviceName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type CurrentDeviceSectionHeadingProps = Pick<Props, "onSignOutCurrentDevice" | "signOutAllOtherSessions"> & {
|
||||
type CurrentDeviceSectionHeadingProps = Pick<
|
||||
Props,
|
||||
"onSignOutCurrentDevice" | "signOutAllOtherSessions" | "otherSessionsCount"
|
||||
> & {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> = ({
|
||||
onSignOutCurrentDevice,
|
||||
signOutAllOtherSessions,
|
||||
otherSessionsCount,
|
||||
disabled,
|
||||
}) => {
|
||||
const menuOptions = [
|
||||
|
@ -61,7 +68,7 @@ const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> =
|
|||
? [
|
||||
<IconizedContextMenuOption
|
||||
key="sign-out-all-others"
|
||||
label={_t("Sign out all other sessions")}
|
||||
label={_t("Sign out of all other sessions (%(otherSessionsCount)s)", { otherSessionsCount })}
|
||||
onClick={signOutAllOtherSessions}
|
||||
isDestructive
|
||||
/>,
|
||||
|
@ -85,6 +92,7 @@ const CurrentDeviceSection: React.FC<Props> = ({
|
|||
isLoading,
|
||||
isSigningOut,
|
||||
localNotificationSettings,
|
||||
otherSessionsCount,
|
||||
setPushNotifications,
|
||||
onVerifyCurrentDevice,
|
||||
onSignOutCurrentDevice,
|
||||
|
@ -100,6 +108,7 @@ const CurrentDeviceSection: React.FC<Props> = ({
|
|||
<CurrentDeviceSectionHeading
|
||||
onSignOutCurrentDevice={onSignOutCurrentDevice}
|
||||
signOutAllOtherSessions={signOutAllOtherSessions}
|
||||
otherSessionsCount={otherSessionsCount}
|
||||
disabled={isLoading || !device || isSigningOut}
|
||||
/>
|
||||
}
|
||||
|
@ -124,11 +133,16 @@ const CurrentDeviceSection: React.FC<Props> = ({
|
|||
onVerifyDevice={onVerifyCurrentDevice}
|
||||
onSignOutDevice={onSignOutCurrentDevice}
|
||||
saveDeviceName={saveDeviceName}
|
||||
className="mx_CurrentDeviceSection_deviceDetails"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyCurrentDevice} />
|
||||
<DeviceVerificationStatusCard
|
||||
device={device}
|
||||
onVerifyDevice={onVerifyCurrentDevice}
|
||||
isCurrentDevice
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { IPusher } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import { PUSHER_ENABLED } from "matrix-js-sdk/src/@types/event";
|
||||
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
|
||||
|
@ -38,6 +39,8 @@ interface Props {
|
|||
saveDeviceName: (deviceName: string) => Promise<void>;
|
||||
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
|
||||
supportsMSC3881?: boolean | undefined;
|
||||
className?: string;
|
||||
isCurrentDevice?: boolean;
|
||||
}
|
||||
|
||||
interface MetadataTable {
|
||||
|
@ -56,6 +59,8 @@ const DeviceDetails: React.FC<Props> = ({
|
|||
saveDeviceName,
|
||||
setPushNotifications,
|
||||
supportsMSC3881,
|
||||
className,
|
||||
isCurrentDevice,
|
||||
}) => {
|
||||
const metadata: MetadataTable[] = [
|
||||
{
|
||||
|
@ -113,10 +118,10 @@ const DeviceDetails: React.FC<Props> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx_DeviceDetails" data-testid={`device-detail-${device.device_id}`}>
|
||||
<div className={classNames("mx_DeviceDetails", className)} data-testid={`device-detail-${device.device_id}`}>
|
||||
<section className="mx_DeviceDetails_section">
|
||||
<DeviceDetailHeading device={device} saveDeviceName={saveDeviceName} />
|
||||
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyDevice} />
|
||||
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyDevice} isCurrentDevice />
|
||||
</section>
|
||||
<section className="mx_DeviceDetails_section">
|
||||
<p className="mx_DeviceDetails_sectionHeading">{_t("Session details")}</p>
|
||||
|
|
|
@ -22,25 +22,30 @@ import DeviceSecurityCard from "./DeviceSecurityCard";
|
|||
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
|
||||
import { DeviceSecurityVariation, ExtendedDevice } from "./types";
|
||||
|
||||
interface Props {
|
||||
export interface DeviceVerificationStatusCardProps {
|
||||
device: ExtendedDevice;
|
||||
isCurrentDevice?: boolean;
|
||||
onVerifyDevice?: () => void;
|
||||
}
|
||||
|
||||
const getCardProps = (
|
||||
device: ExtendedDevice,
|
||||
isCurrentDevice?: boolean,
|
||||
): {
|
||||
variation: DeviceSecurityVariation;
|
||||
heading: string;
|
||||
description: React.ReactNode;
|
||||
} => {
|
||||
if (device.isVerified) {
|
||||
const descriptionText = isCurrentDevice
|
||||
? _t("Your current session is ready for secure messaging.")
|
||||
: _t("This session is ready for secure messaging.");
|
||||
return {
|
||||
variation: DeviceSecurityVariation.Verified,
|
||||
heading: _t("Verified session"),
|
||||
description: (
|
||||
<>
|
||||
{_t("This session is ready for secure messaging.")}
|
||||
{descriptionText}
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
|
||||
</>
|
||||
),
|
||||
|
@ -59,20 +64,27 @@ const getCardProps = (
|
|||
};
|
||||
}
|
||||
|
||||
const descriptionText = isCurrentDevice
|
||||
? _t("Verify your current session for enhanced secure messaging.")
|
||||
: _t("Verify or sign out from this session for best security and reliability.");
|
||||
return {
|
||||
variation: DeviceSecurityVariation.Unverified,
|
||||
heading: _t("Unverified session"),
|
||||
description: (
|
||||
<>
|
||||
{_t("Verify or sign out from this session for best security and reliability.")}
|
||||
{descriptionText}
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const DeviceVerificationStatusCard: React.FC<Props> = ({ device, onVerifyDevice }) => {
|
||||
const securityCardProps = getCardProps(device);
|
||||
export const DeviceVerificationStatusCard: React.FC<DeviceVerificationStatusCardProps> = ({
|
||||
device,
|
||||
isCurrentDevice,
|
||||
onVerifyDevice,
|
||||
}) => {
|
||||
const securityCardProps = getCardProps(device, isCurrentDevice);
|
||||
|
||||
return (
|
||||
<DeviceSecurityCard {...securityCardProps}>
|
||||
|
|
|
@ -213,6 +213,7 @@ const DeviceListItem: React.FC<{
|
|||
saveDeviceName={saveDeviceName}
|
||||
setPushNotifications={setPushNotifications}
|
||||
supportsMSC3881={supportsMSC3881}
|
||||
className="mx_FilteredDeviceList_deviceDetails"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
|
||||
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
|
||||
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
|
||||
|
||||
interface Props {
|
||||
// total count of other sessions
|
||||
// excludes current sessions
|
||||
// not affected by filters
|
||||
otherSessionsCount: number;
|
||||
disabled?: boolean;
|
||||
signOutAllOtherSessions: () => void;
|
||||
}
|
||||
|
||||
export const OtherSessionsSectionHeading: React.FC<Props> = ({
|
||||
otherSessionsCount,
|
||||
disabled,
|
||||
signOutAllOtherSessions,
|
||||
}) => {
|
||||
const menuOptions = [
|
||||
<IconizedContextMenuOption
|
||||
key="sign-out-all-others"
|
||||
label={_t("Sign out of %(count)s sessions", { count: otherSessionsCount })}
|
||||
onClick={signOutAllOtherSessions}
|
||||
isDestructive
|
||||
/>,
|
||||
];
|
||||
return (
|
||||
<SettingsSubsectionHeading heading={_t("Other sessions")}>
|
||||
<KebabContextMenu
|
||||
disabled={disabled}
|
||||
title={_t("Options")}
|
||||
options={menuOptions}
|
||||
data-testid="other-sessions-menu"
|
||||
/>
|
||||
</SettingsSubsectionHeading>
|
||||
);
|
||||
};
|
|
@ -35,7 +35,7 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto";
|
|||
|
||||
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
|
||||
import { getDeviceClientInformation, pruneClientInformation } from "../../../../utils/device/clientInformation";
|
||||
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
|
||||
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
|
||||
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
|
||||
|
@ -116,8 +116,8 @@ export type DevicesState = {
|
|||
export const useOwnDevices = (): DevicesState => {
|
||||
const matrixClient = useContext(MatrixClientContext);
|
||||
|
||||
const currentDeviceId = matrixClient.getDeviceId();
|
||||
const userId = matrixClient.getUserId();
|
||||
const currentDeviceId = matrixClient.getDeviceId()!;
|
||||
const userId = matrixClient.getSafeUserId();
|
||||
|
||||
const [devices, setDevices] = useState<DevicesState["devices"]>({});
|
||||
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
|
||||
|
@ -138,11 +138,6 @@ export const useOwnDevices = (): DevicesState => {
|
|||
const refreshDevices = useCallback(async () => {
|
||||
setIsLoadingDeviceList(true);
|
||||
try {
|
||||
// realistically we should never hit this
|
||||
// but it satisfies types
|
||||
if (!userId) {
|
||||
throw new Error("Cannot fetch devices without user id");
|
||||
}
|
||||
const devices = await fetchDevicesWithVerification(matrixClient, userId);
|
||||
setDevices(devices);
|
||||
|
||||
|
@ -176,6 +171,15 @@ export const useOwnDevices = (): DevicesState => {
|
|||
refreshDevices();
|
||||
}, [refreshDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const deviceIds = Object.keys(devices);
|
||||
// empty devices means devices have not been fetched yet
|
||||
// as there is always at least the current device
|
||||
if (deviceIds.length) {
|
||||
pruneClientInformation(deviceIds, matrixClient);
|
||||
}
|
||||
}, [devices, matrixClient]);
|
||||
|
||||
useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => {
|
||||
if (users.includes(userId)) {
|
||||
refreshDevices();
|
||||
|
|
|
@ -38,6 +38,7 @@ import SettingsStore from "../../../../../settings/SettingsStore";
|
|||
import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo";
|
||||
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||
import { FilterVariation } from "../../devices/filter";
|
||||
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
|
||||
|
||||
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
|
@ -156,7 +157,8 @@ const SessionManagerTab: React.FC = () => {
|
|||
};
|
||||
|
||||
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
||||
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
|
||||
const otherSessionsCount = Object.keys(otherDevices).length;
|
||||
const shouldShowOtherSessions = otherSessionsCount > 0;
|
||||
|
||||
const onVerifyCurrentDevice = () => {
|
||||
Modal.createDialog(SetupEncryptionDialog as unknown as React.ComponentType, { onFinished: refreshDevices });
|
||||
|
@ -241,10 +243,17 @@ const SessionManagerTab: React.FC = () => {
|
|||
onVerifyCurrentDevice={onVerifyCurrentDevice}
|
||||
onSignOutCurrentDevice={onSignOutCurrentDevice}
|
||||
signOutAllOtherSessions={signOutAllOtherSessions}
|
||||
otherSessionsCount={otherSessionsCount}
|
||||
/>
|
||||
{shouldShowOtherSessions && (
|
||||
<SettingsSubsection
|
||||
heading={_t("Other sessions")}
|
||||
heading={
|
||||
<OtherSessionsSectionHeading
|
||||
otherSessionsCount={otherSessionsCount}
|
||||
signOutAllOtherSessions={signOutAllOtherSessions!}
|
||||
disabled={!!signingOutDeviceIds.length}
|
||||
/>
|
||||
}
|
||||
description={_t(
|
||||
`For best security, verify your sessions and sign out ` +
|
||||
`from any session that you don't recognize or use anymore.`,
|
||||
|
|
|
@ -30,7 +30,7 @@ export function UserOnboardingFeedback() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx_UserOnboardingFeedback">
|
||||
<div className="mx_UserOnboardingFeedback" data-testid="user-onboarding-feedback">
|
||||
<div className="mx_UserOnboardingFeedback_content">
|
||||
<Heading size="h4" className="mx_UserOnboardingFeedback_title">
|
||||
{_t("How are you finding %(brand)s so far?", {
|
||||
|
|
|
@ -15,9 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
|
||||
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import ProgressBar from "../../views/elements/ProgressBar";
|
||||
|
@ -25,26 +24,26 @@ import Heading from "../../views/typography/Heading";
|
|||
import { UserOnboardingFeedback } from "./UserOnboardingFeedback";
|
||||
import { UserOnboardingTask } from "./UserOnboardingTask";
|
||||
|
||||
export const getUserOnboardingCounters = (tasks: UserOnboardingTaskWithResolvedCompletion[]) => {
|
||||
const completed = tasks.filter((task) => task.completed === true).length;
|
||||
const waiting = tasks.filter((task) => task.completed === false).length;
|
||||
|
||||
return {
|
||||
completed: completed,
|
||||
waiting: waiting,
|
||||
total: completed + waiting,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
completedTasks: Task[];
|
||||
waitingTasks: Task[];
|
||||
tasks: UserOnboardingTaskWithResolvedCompletion[];
|
||||
}
|
||||
|
||||
export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
|
||||
const completed = completedTasks.length;
|
||||
const waiting = waitingTasks.length;
|
||||
const total = completed + waiting;
|
||||
|
||||
const tasks = useMemo(
|
||||
() => [
|
||||
...completedTasks.map((it): [Task, boolean] => [it, true]),
|
||||
...waitingTasks.map((it): [Task, boolean] => [it, false]),
|
||||
],
|
||||
[completedTasks, waitingTasks],
|
||||
);
|
||||
export function UserOnboardingList({ tasks }: Props) {
|
||||
const { completed, waiting, total } = getUserOnboardingCounters(tasks);
|
||||
|
||||
return (
|
||||
<div className="mx_UserOnboardingList">
|
||||
<div className="mx_UserOnboardingList" data-testid="user-onboarding-list">
|
||||
<div className="mx_UserOnboardingList_header">
|
||||
<Heading size="h3" className="mx_UserOnboardingList_title">
|
||||
{waiting > 0
|
||||
|
@ -64,8 +63,8 @@ export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
|
|||
{waiting === 0 && <UserOnboardingFeedback />}
|
||||
</div>
|
||||
<ol className="mx_UserOnboardingList_list">
|
||||
{tasks.map(([task, completed]) => (
|
||||
<UserOnboardingTask key={task.id} completed={completed} task={task} />
|
||||
{tasks.map((task) => (
|
||||
<UserOnboardingTask key={task.id} completed={task.completed} task={task} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
|
|
@ -49,7 +49,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
|
|||
|
||||
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
|
||||
const context = useUserOnboardingContext();
|
||||
const [completedTasks, waitingTasks] = useUserOnboardingTasks(context);
|
||||
const tasks = useUserOnboardingTasks(context);
|
||||
|
||||
const initialSyncComplete = useInitialSyncComplete();
|
||||
const [showList, setShowList] = useState<boolean>(false);
|
||||
|
@ -80,7 +80,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
|
|||
return (
|
||||
<AutoHideScrollbar className="mx_UserOnboardingPage">
|
||||
<UserOnboardingHeader useCase={useCase} />
|
||||
{showList && <UserOnboardingList completedTasks={completedTasks} waitingTasks={waitingTasks} />}
|
||||
{showList && <UserOnboardingList tasks={tasks} />}
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,12 +17,12 @@ limitations under the License.
|
|||
import classNames from "classnames";
|
||||
import * as React from "react";
|
||||
|
||||
import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
|
||||
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import Heading from "../../views/typography/Heading";
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
task: UserOnboardingTaskWithResolvedCompletion;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,7 @@ export function UserOnboardingTask({ task, completed = false }: Props) {
|
|||
|
||||
return (
|
||||
<li
|
||||
data-testid="user-onboarding-task"
|
||||
className={classNames("mx_UserOnboardingTask", {
|
||||
mx_UserOnboardingTask_completed: completed,
|
||||
})}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import PipView from "./PipView";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {}
|
||||
|
||||
export default class PiPContainer extends React.PureComponent<IProps, IState> {
|
||||
public render() {
|
||||
return (
|
||||
<div className="mx_PiPContainer">
|
||||
<PipView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -158,7 +158,7 @@ export class SdkContextClass {
|
|||
|
||||
public get voiceBroadcastRecordingsStore(): VoiceBroadcastRecordingsStore {
|
||||
if (!this._VoiceBroadcastRecordingsStore) {
|
||||
this._VoiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance();
|
||||
this._VoiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
|
||||
}
|
||||
return this._VoiceBroadcastRecordingsStore;
|
||||
}
|
||||
|
@ -172,7 +172,7 @@ export class SdkContextClass {
|
|||
|
||||
public get voiceBroadcastPlaybacksStore(): VoiceBroadcastPlaybacksStore {
|
||||
if (!this._VoiceBroadcastPlaybacksStore) {
|
||||
this._VoiceBroadcastPlaybacksStore = VoiceBroadcastPlaybacksStore.instance();
|
||||
this._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(this.voiceBroadcastRecordingsStore);
|
||||
}
|
||||
return this._VoiceBroadcastPlaybacksStore;
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
|
|||
import { MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getShareableLocationEventForBeacon } from "../../utils/beacon/getShareableLocation";
|
||||
import { VoiceBroadcastInfoEventType } from "../../voice-broadcast/types";
|
||||
|
||||
/**
|
||||
* Get forwardable event for a given event
|
||||
|
@ -29,6 +30,8 @@ export const getForwardableEvent = (event: MatrixEvent, cli: MatrixClient): Matr
|
|||
return null;
|
||||
}
|
||||
|
||||
if (event.getType() === VoiceBroadcastInfoEventType) return null;
|
||||
|
||||
// Live location beacons should forward their latest location as a static pin location
|
||||
// If the beacon is not live, or doesn't have a location forwarding is not allowed
|
||||
if (M_BEACON_INFO.matches(event.getType())) {
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
import { VoiceBroadcastInfoEventType } from "../voice-broadcast";
|
||||
|
||||
export const getReferenceRelationsForEvent = (
|
||||
event: MatrixEvent,
|
||||
messageType: EventType | typeof VoiceBroadcastInfoEventType,
|
||||
client: MatrixClient,
|
||||
): Relations | undefined => {
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
return room
|
||||
?.getUnfilteredTimelineSet()
|
||||
?.relations?.getChildEventsForEvent(event.getId(), RelationType.Reference, messageType);
|
||||
};
|
|
@ -16,4 +16,3 @@ limitations under the License.
|
|||
|
||||
export { getForwardableEvent } from "./forward/getForwardableEvent";
|
||||
export { getShareableLocationEvent } from "./location/getShareableLocationEvent";
|
||||
export * from "./getReferenceRelationsForEvent";
|
||||
|
|
|
@ -33,6 +33,11 @@ export const useCall = (roomId: string): Call | null => {
|
|||
return call;
|
||||
};
|
||||
|
||||
export const useCallForWidget = (widgetId: string, roomId: string): Call | null => {
|
||||
const call = useCall(roomId);
|
||||
return call?.widget.id === widgetId ? call : null;
|
||||
};
|
||||
|
||||
export const useConnectionState = (call: Call): ConnectionState =>
|
||||
useTypedEventEmitterState(
|
||||
call,
|
||||
|
|
|
@ -82,7 +82,7 @@ function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: Matri
|
|||
return value;
|
||||
}
|
||||
|
||||
export function useUserOnboardingContext(): UserOnboardingContext | null {
|
||||
export function useUserOnboardingContext(): UserOnboardingContext {
|
||||
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
|
||||
const profile = await cli.getProfileInfo(cli.getUserId());
|
||||
return Boolean(profile?.avatar_url);
|
||||
|
|
|
@ -30,7 +30,7 @@ import { UseCase } from "../settings/enums/UseCase";
|
|||
import { useSettingValue } from "./useSettings";
|
||||
import { UserOnboardingContext } from "./useUserOnboardingContext";
|
||||
|
||||
export interface UserOnboardingTask {
|
||||
interface UserOnboardingTask {
|
||||
id: string;
|
||||
title: string | (() => string);
|
||||
description: string | (() => string);
|
||||
|
@ -41,10 +41,11 @@ export interface UserOnboardingTask {
|
|||
href?: string;
|
||||
hideOnComplete?: boolean;
|
||||
};
|
||||
completed: (ctx: UserOnboardingContext) => boolean;
|
||||
}
|
||||
|
||||
interface InternalUserOnboardingTask extends UserOnboardingTask {
|
||||
completed: (ctx: UserOnboardingContext) => boolean;
|
||||
export interface UserOnboardingTaskWithResolvedCompletion extends Omit<UserOnboardingTask, "completed"> {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const onClickStartDm = (ev: ButtonEvent) => {
|
||||
|
@ -52,7 +53,7 @@ const onClickStartDm = (ev: ButtonEvent) => {
|
|||
defaultDispatcher.dispatch({ action: "view_create_chat" });
|
||||
};
|
||||
|
||||
const tasks: InternalUserOnboardingTask[] = [
|
||||
const tasks: UserOnboardingTask[] = [
|
||||
{
|
||||
id: "create-account",
|
||||
title: _t("Create account"),
|
||||
|
@ -143,9 +144,15 @@ const tasks: InternalUserOnboardingTask[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function useUserOnboardingTasks(context: UserOnboardingContext): [UserOnboardingTask[], UserOnboardingTask[]] {
|
||||
export function useUserOnboardingTasks(context: UserOnboardingContext) {
|
||||
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection") ?? UseCase.Skip;
|
||||
const relevantTasks = useMemo(() => tasks.filter((it) => !it.relevant || it.relevant.includes(useCase)), [useCase]);
|
||||
const completedTasks = relevantTasks.filter((it) => context && it.completed(context));
|
||||
return [completedTasks, relevantTasks.filter((it) => !completedTasks.includes(it))];
|
||||
|
||||
return useMemo<UserOnboardingTaskWithResolvedCompletion[]>(() => {
|
||||
return tasks
|
||||
.filter((task) => !task.relevant || task.relevant.includes(useCase))
|
||||
.map((task) => ({
|
||||
...task,
|
||||
completed: task.completed(context),
|
||||
}));
|
||||
}, [context, useCase]);
|
||||
}
|
||||
|
|
|
@ -390,6 +390,8 @@
|
|||
"Power level must be positive integer.": "Power level must be positive integer.",
|
||||
"You are not in this room.": "You are not in this room.",
|
||||
"You do not have permission to do that in this room.": "You do not have permission to do that in this room.",
|
||||
"Failed to send event": "Failed to send event",
|
||||
"Failed to read events": "Failed to read events",
|
||||
"Missing room_id in request": "Missing room_id in request",
|
||||
"Room %(roomId)s not visible": "Room %(roomId)s not visible",
|
||||
"Missing user_id in request": "Missing user_id in request",
|
||||
|
@ -648,6 +650,8 @@
|
|||
"You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.",
|
||||
"You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.",
|
||||
"Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.",
|
||||
"Connection error": "Connection error",
|
||||
"Unfortunately we're unable to start a recording right now. Please try again later.": "Unfortunately we're unable to start a recording right now. Please try again later.",
|
||||
"Can’t start a call": "Can’t start a call",
|
||||
"You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.": "You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.",
|
||||
"You ended a <a>voice broadcast</a>": "You ended a <a>voice broadcast</a>",
|
||||
|
@ -657,6 +661,10 @@
|
|||
"Stop live broadcasting?": "Stop live broadcasting?",
|
||||
"Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.",
|
||||
"Yes, stop broadcast": "Yes, stop broadcast",
|
||||
"Listen to live broadcast?": "Listen to live broadcast?",
|
||||
"If you start listening to this live broadcast, your current live broadcast recording will be ended.": "If you start listening to this live broadcast, your current live broadcast recording will be ended.",
|
||||
"Yes, end my recording": "Yes, end my recording",
|
||||
"No": "No",
|
||||
"30s backward": "30s backward",
|
||||
"30s forward": "30s forward",
|
||||
"Go live": "Go live",
|
||||
|
@ -813,7 +821,6 @@
|
|||
"Learn more": "Learn more",
|
||||
"Share anonymous data to help us identify issues. Nothing personal. No third parties. <LearnMoreLink>Learn More</LearnMoreLink>": "Share anonymous data to help us identify issues. Nothing personal. No third parties. <LearnMoreLink>Learn More</LearnMoreLink>",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
"Help improve %(analyticsOwner)s": "Help improve %(analyticsOwner)s",
|
||||
"You have unverified sessions": "You have unverified sessions",
|
||||
"Review to ensure your account is safe": "Review to ensure your account is safe",
|
||||
|
@ -948,7 +955,7 @@
|
|||
"Temporary implementation. Locations persist in room history.": "Temporary implementation. Locations persist in room history.",
|
||||
"Favourite Messages": "Favourite Messages",
|
||||
"Under active development.": "Under active development.",
|
||||
"Under active development": "Under active development",
|
||||
"Force 15s voice broadcast chunk length": "Force 15s voice broadcast chunk length",
|
||||
"Use new session manager": "Use new session manager",
|
||||
"New session manager": "New session manager",
|
||||
"Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.",
|
||||
|
@ -1414,7 +1421,7 @@
|
|||
"Enable desktop notifications for this session": "Enable desktop notifications for this session",
|
||||
"Show message in desktop notification": "Show message in desktop notification",
|
||||
"Enable audible notifications for this session": "Enable audible notifications for this session",
|
||||
"Clear notifications": "Clear notifications",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"Keyword": "Keyword",
|
||||
"New keyword": "New keyword",
|
||||
"On": "On",
|
||||
|
@ -1624,7 +1631,6 @@
|
|||
"Sign out": "Sign out",
|
||||
"Are you sure you want to sign out of %(count)s sessions?|other": "Are you sure you want to sign out of %(count)s sessions?",
|
||||
"Are you sure you want to sign out of %(count)s sessions?|one": "Are you sure you want to sign out of %(count)s session?",
|
||||
"Other sessions": "Other sessions",
|
||||
"For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.",
|
||||
"Sidebar": "Sidebar",
|
||||
"Spaces to show": "Spaces to show",
|
||||
|
@ -1762,7 +1768,7 @@
|
|||
"Please enter verification code sent via text.": "Please enter verification code sent via text.",
|
||||
"Verification code": "Verification code",
|
||||
"Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.",
|
||||
"Sign out all other sessions": "Sign out all other sessions",
|
||||
"Sign out of all other sessions (%(otherSessionsCount)s)": "Sign out of all other sessions (%(otherSessionsCount)s)",
|
||||
"Current session": "Current session",
|
||||
"Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.",
|
||||
"Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.",
|
||||
|
@ -1815,9 +1821,11 @@
|
|||
"Mobile session": "Mobile session",
|
||||
"Web session": "Web session",
|
||||
"Unknown session type": "Unknown session type",
|
||||
"Verified session": "Verified session",
|
||||
"Your current session is ready for secure messaging.": "Your current session is ready for secure messaging.",
|
||||
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
|
||||
"Verified session": "Verified session",
|
||||
"This session doesn't support encryption and thus can't be verified.": "This session doesn't support encryption and thus can't be verified.",
|
||||
"Verify your current session for enhanced secure messaging.": "Verify your current session for enhanced secure messaging.",
|
||||
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
|
||||
"Verify session": "Verify session",
|
||||
"For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
|
||||
|
@ -1840,6 +1848,9 @@
|
|||
"Sign in with QR code": "Sign in with QR code",
|
||||
"You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.",
|
||||
"Show QR code": "Show QR code",
|
||||
"Sign out of %(count)s sessions|other": "Sign out of %(count)s sessions",
|
||||
"Sign out of %(count)s sessions|one": "Sign out of %(count)s session",
|
||||
"Other sessions": "Other sessions",
|
||||
"Security recommendations": "Security recommendations",
|
||||
"Improve your account security by following these recommendations.": "Improve your account security by following these recommendations.",
|
||||
"View all": "View all",
|
||||
|
@ -1877,9 +1888,7 @@
|
|||
"Mod": "Mod",
|
||||
"From a thread": "From a thread",
|
||||
"This event could not be displayed": "This event could not be displayed",
|
||||
"Message Actions": "Message Actions",
|
||||
"View in room": "View in room",
|
||||
"Copy link to thread": "Copy link to thread",
|
||||
" in <strong>%(room)s</strong>": " in <strong>%(room)s</strong>",
|
||||
"Encrypted by an unverified session": "Encrypted by an unverified session",
|
||||
"Unencrypted": "Unencrypted",
|
||||
"Encrypted by a deleted session": "Encrypted by a deleted session",
|
||||
|
@ -2119,7 +2128,6 @@
|
|||
"%(count)s reply|one": "%(count)s reply",
|
||||
"Open thread": "Open thread",
|
||||
"Jump to first unread message.": "Jump to first unread message.",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"Unable to access your microphone": "Unable to access your microphone",
|
||||
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
|
||||
"No microphone found": "No microphone found",
|
||||
|
@ -2128,6 +2136,12 @@
|
|||
"Italic": "Italic",
|
||||
"Underline": "Underline",
|
||||
"Code": "Code",
|
||||
"Link": "Link",
|
||||
"Create a link": "Create a link",
|
||||
"Text": "Text",
|
||||
"Message Actions": "Message Actions",
|
||||
"View in room": "View in room",
|
||||
"Copy link to thread": "Copy link to thread",
|
||||
"Error updating main address": "Error updating main address",
|
||||
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
|
||||
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
|
||||
|
@ -3182,6 +3196,7 @@
|
|||
"Copy room link": "Copy room link",
|
||||
"Low Priority": "Low Priority",
|
||||
"Forget Room": "Forget Room",
|
||||
"Mark as read": "Mark as read",
|
||||
"Use default": "Use default",
|
||||
"Mentions & Keywords": "Mentions & Keywords",
|
||||
"See room timeline (devtools)": "See room timeline (devtools)",
|
||||
|
|
|
@ -53,6 +53,7 @@ import { getCurrentLanguage } from "../languageHandler";
|
|||
import DesktopCapturerSourcePicker from "../components/views/elements/DesktopCapturerSourcePicker";
|
||||
import Modal from "../Modal";
|
||||
import { FontWatcher } from "../settings/watchers/FontWatcher";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
|
||||
const TIMEOUT_MS = 16000;
|
||||
|
||||
|
@ -254,6 +255,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
}
|
||||
|
||||
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
|
||||
WidgetMessagingStore.instance.on(WidgetMessagingStoreEvent.StopMessaging, this.onStopMessaging);
|
||||
window.addEventListener("beforeunload", this.beforeUnload);
|
||||
this.connectionState = ConnectionState.Connected;
|
||||
}
|
||||
|
@ -274,6 +276,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
*/
|
||||
public setDisconnected() {
|
||||
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
|
||||
WidgetMessagingStore.instance.off(WidgetMessagingStoreEvent.StopMessaging, this.onStopMessaging);
|
||||
window.removeEventListener("beforeunload", this.beforeUnload);
|
||||
this.messaging = null;
|
||||
this.connectionState = ConnectionState.Disconnected;
|
||||
|
@ -291,6 +294,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
if (membership !== "join") this.setDisconnected();
|
||||
};
|
||||
|
||||
private onStopMessaging = (uid: string) => {
|
||||
if (uid === this.widgetUid) {
|
||||
logger.log("The widget died; treating this as a user hangup");
|
||||
this.setDisconnected();
|
||||
}
|
||||
};
|
||||
|
||||
private beforeUnload = () => this.setDisconnected();
|
||||
}
|
||||
|
||||
|
@ -626,6 +636,15 @@ export class ElementCall extends Call {
|
|||
}
|
||||
|
||||
private constructor(public readonly groupCall: GroupCall, client: MatrixClient) {
|
||||
const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE);
|
||||
// The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget.
|
||||
// We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible).
|
||||
// This is prohibited in EC where a hashed version of the analyticsID is used for the actual posthog identification.
|
||||
// We can pass the raw EW analyticsID here since we need to trust EC with not sending sensitive data to posthog (EC has access to more sensible data than the analyticsID e.g. the username)
|
||||
const analyticsID: string = accountAnalyticsData?.getContent().pseudonymousAnalyticsOptIn
|
||||
? accountAnalyticsData?.getContent().id
|
||||
: "";
|
||||
|
||||
// Splice together the Element Call URL for this call
|
||||
const params = new URLSearchParams({
|
||||
embed: "",
|
||||
|
@ -637,6 +656,7 @@ export class ElementCall extends Call {
|
|||
baseUrl: client.baseUrl,
|
||||
lang: getCurrentLanguage().replace("_", "-"),
|
||||
fontScale: `${SettingsStore.getValue("baseFontSize") / FontWatcher.DEFAULT_SIZE}`,
|
||||
analyticsID,
|
||||
});
|
||||
|
||||
// Set custom fonts
|
||||
|
|
|
@ -90,6 +90,7 @@ export enum LabGroup {
|
|||
|
||||
export enum Features {
|
||||
VoiceBroadcast = "feature_voice_broadcast",
|
||||
VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks",
|
||||
}
|
||||
|
||||
export const labGroupNames: Record<LabGroup, string> = {
|
||||
|
@ -262,7 +263,7 @@ export const SETTINGS: { [setting: string]: ISetting } = {
|
|||
controller: new ThreadBetaController(),
|
||||
displayName: _td("Threaded messages"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: true,
|
||||
default: false,
|
||||
betaInfo: {
|
||||
title: _td("Threaded messages"),
|
||||
caption: () => (
|
||||
|
@ -458,7 +459,11 @@ export const SETTINGS: { [setting: string]: ISetting } = {
|
|||
labsGroup: LabGroup.Messaging,
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
displayName: _td("Voice broadcast"),
|
||||
description: _td("Under active development"),
|
||||
default: false,
|
||||
},
|
||||
[Features.VoiceBroadcastForceSmallChunks]: {
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
|
||||
displayName: _td("Force 15s voice broadcast chunk length"),
|
||||
default: false,
|
||||
},
|
||||
"feature_new_device_manager": {
|
||||
|
|
|
@ -54,9 +54,12 @@ import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload";
|
|||
import {
|
||||
doClearCurrentVoiceBroadcastPlaybackIfStopped,
|
||||
doMaybeSetCurrentVoiceBroadcastPlayback,
|
||||
VoiceBroadcastRecording,
|
||||
VoiceBroadcastRecordingsStoreEvent,
|
||||
} from "../voice-broadcast";
|
||||
import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators";
|
||||
import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog";
|
||||
import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom";
|
||||
|
||||
const NUM_JOIN_RETRY = 5;
|
||||
|
||||
|
@ -152,6 +155,10 @@ export class RoomViewStore extends EventEmitter {
|
|||
public constructor(dis: MatrixDispatcher, private readonly stores: SdkContextClass) {
|
||||
super();
|
||||
this.resetDispatcher(dis);
|
||||
this.stores.voiceBroadcastRecordingsStore.addListener(
|
||||
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
|
||||
this.onCurrentBroadcastRecordingChanged,
|
||||
);
|
||||
}
|
||||
|
||||
public addRoomListener(roomId: string, fn: Listener): void {
|
||||
|
@ -166,13 +173,23 @@ export class RoomViewStore extends EventEmitter {
|
|||
this.emit(roomId, isActive);
|
||||
}
|
||||
|
||||
private onCurrentBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null) => {
|
||||
if (recording === null) {
|
||||
const room = this.stores.client?.getRoom(this.state.roomId || undefined);
|
||||
|
||||
if (room) {
|
||||
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private setState(newState: Partial<State>): void {
|
||||
// If values haven't changed, there's nothing to do.
|
||||
// This only tries a shallow comparison, so unchanged objects will slip
|
||||
// through, but that's probably okay for now.
|
||||
let stateChanged = false;
|
||||
for (const key of Object.keys(newState)) {
|
||||
if (this.state[key] !== newState[key]) {
|
||||
if (this.state[key as keyof State] !== newState[key as keyof State]) {
|
||||
stateChanged = true;
|
||||
break;
|
||||
}
|
||||
|
@ -445,6 +462,7 @@ export class RoomViewStore extends EventEmitter {
|
|||
}
|
||||
|
||||
if (room) {
|
||||
pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore);
|
||||
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
|
||||
}
|
||||
} else if (payload.room_alias) {
|
||||
|
|
|
@ -66,7 +66,6 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
|||
import Modal from "../../Modal";
|
||||
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
|
||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||
import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast";
|
||||
|
||||
// TODO: Destroy all of this code
|
||||
|
||||
|
@ -292,7 +291,7 @@ export class StopGapWidget extends EventEmitter {
|
|||
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
|
||||
this.messaging.on(`action:${ElementWidgetActions.JoinCall}`, () => {
|
||||
// pause voice broadcast recording when any widget sends a "join"
|
||||
VoiceBroadcastRecordingsStore.instance().getCurrent()?.pause();
|
||||
SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()?.pause();
|
||||
});
|
||||
|
||||
// Always attach a handler for ViewRoom, but permission check it internally
|
||||
|
|
|
@ -18,12 +18,33 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import { M_POLL_START } from "matrix-events-sdk";
|
||||
import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
|
||||
import { IContent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils";
|
||||
import { ElementCall } from "../models/Call";
|
||||
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast";
|
||||
|
||||
const calcIsInfoMessage = (
|
||||
eventType: EventType | string,
|
||||
content: IContent,
|
||||
isBubbleMessage: boolean,
|
||||
isLeftAlignedBubbleMessage: boolean,
|
||||
): boolean => {
|
||||
return (
|
||||
!isBubbleMessage &&
|
||||
!isLeftAlignedBubbleMessage &&
|
||||
eventType !== EventType.RoomMessage &&
|
||||
eventType !== EventType.RoomMessageEncrypted &&
|
||||
eventType !== EventType.Sticker &&
|
||||
eventType !== EventType.RoomCreate &&
|
||||
!M_POLL_START.matches(eventType) &&
|
||||
!M_BEACON_INFO.matches(eventType) &&
|
||||
!(eventType === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started)
|
||||
);
|
||||
};
|
||||
|
||||
export function getEventDisplayInfo(
|
||||
mxEvent: MatrixEvent,
|
||||
|
@ -67,21 +88,14 @@ export function getEventDisplayInfo(
|
|||
factory === JitsiEventFactory;
|
||||
const isLeftAlignedBubbleMessage =
|
||||
!isBubbleMessage && (eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType));
|
||||
let isInfoMessage =
|
||||
!isBubbleMessage &&
|
||||
!isLeftAlignedBubbleMessage &&
|
||||
eventType !== EventType.RoomMessage &&
|
||||
eventType !== EventType.RoomMessageEncrypted &&
|
||||
eventType !== EventType.Sticker &&
|
||||
eventType !== EventType.RoomCreate &&
|
||||
!M_POLL_START.matches(eventType) &&
|
||||
!M_BEACON_INFO.matches(eventType);
|
||||
let isInfoMessage = calcIsInfoMessage(eventType, content, isBubbleMessage, isLeftAlignedBubbleMessage);
|
||||
// Some non-info messages want to be rendered in the appropriate bubble column but without the bubble background
|
||||
const noBubbleEvent =
|
||||
(eventType === EventType.RoomMessage && msgtype === MsgType.Emote) ||
|
||||
M_POLL_START.matches(eventType) ||
|
||||
M_BEACON_INFO.matches(eventType) ||
|
||||
isLocationEvent(mxEvent);
|
||||
isLocationEvent(mxEvent) ||
|
||||
eventType === VoiceBroadcastInfoEventType;
|
||||
|
||||
// If we're showing hidden events in the timeline, we should use the
|
||||
// source tile when there's no regular tile for an event and also for
|
||||
|
|
|
@ -32,6 +32,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
|
|||
import { launchPollEditor } from "../components/views/messages/MPollBody";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
||||
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast/types";
|
||||
|
||||
/**
|
||||
* Returns whether an event should allow actions like reply, reactions, edit, etc.
|
||||
|
@ -56,7 +57,9 @@ export function isContentActionable(mxEvent: MatrixEvent): boolean {
|
|||
} else if (
|
||||
mxEvent.getType() === "m.sticker" ||
|
||||
M_POLL_START.matches(mxEvent.getType()) ||
|
||||
M_BEACON_INFO.matches(mxEvent.getType())
|
||||
M_BEACON_INFO.matches(mxEvent.getType()) ||
|
||||
(mxEvent.getType() === VoiceBroadcastInfoEventType &&
|
||||
mxEvent.getContent()?.state === VoiceBroadcastInfoState.Started)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -313,3 +313,13 @@ export const concat = (...arrays: Uint8Array[]): Uint8Array => {
|
|||
return concatenated;
|
||||
}, new Uint8Array(0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Async version of Array.every.
|
||||
*/
|
||||
export async function asyncEvery<T>(values: T[], predicate: (value: T) => Promise<boolean>): Promise<boolean> {
|
||||
for (const value of values) {
|
||||
if (!(await predicate(value))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -40,8 +40,8 @@ const formatUrl = (): string | undefined => {
|
|||
].join("");
|
||||
};
|
||||
|
||||
export const getClientInformationEventType = (deviceId: string): string =>
|
||||
`io.element.matrix_client_information.${deviceId}`;
|
||||
const clientInformationEventPrefix = "io.element.matrix_client_information.";
|
||||
export const getClientInformationEventType = (deviceId: string): string => `${clientInformationEventPrefix}${deviceId}`;
|
||||
|
||||
/**
|
||||
* Record extra client information for the current device
|
||||
|
@ -52,7 +52,7 @@ export const recordClientInformation = async (
|
|||
sdkConfig: IConfigOptions,
|
||||
platform: BasePlatform,
|
||||
): Promise<void> => {
|
||||
const deviceId = matrixClient.getDeviceId();
|
||||
const deviceId = matrixClient.getDeviceId()!;
|
||||
const { brand } = sdkConfig;
|
||||
const version = await platform.getAppVersion();
|
||||
const type = getClientInformationEventType(deviceId);
|
||||
|
@ -66,12 +66,27 @@ export const recordClientInformation = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Remove extra client information
|
||||
* @todo(kerrya) revisit after MSC3391: account data deletion is done
|
||||
* (PSBE-12)
|
||||
* Remove client information events for devices that no longer exist
|
||||
* @param validDeviceIds - ids of current devices,
|
||||
* client information for devices NOT in this list will be removed
|
||||
*/
|
||||
export const pruneClientInformation = (validDeviceIds: string[], matrixClient: MatrixClient): void => {
|
||||
Object.values(matrixClient.store.accountData).forEach((event) => {
|
||||
if (!event.getType().startsWith(clientInformationEventPrefix)) {
|
||||
return;
|
||||
}
|
||||
const [, deviceId] = event.getType().split(clientInformationEventPrefix);
|
||||
if (deviceId && !validDeviceIds.includes(deviceId)) {
|
||||
matrixClient.deleteAccountData(event.getType());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove extra client information for current device
|
||||
*/
|
||||
export const removeClientInformation = async (matrixClient: MatrixClient): Promise<void> => {
|
||||
const deviceId = matrixClient.getDeviceId();
|
||||
const deviceId = matrixClient.getDeviceId()!;
|
||||
const type = getClientInformationEventType(deviceId);
|
||||
const clientInformation = getDeviceClientInformation(matrixClient, deviceId);
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ export default abstract class Exporter {
|
|||
|
||||
protected async downloadZIP(): Promise<string | void> {
|
||||
const filename = this.destinationFileName;
|
||||
const filenameWithoutExt = filename.substring(0, filename.length - 4); // take off the .zip
|
||||
const filenameWithoutExt = filename.substring(0, filename.lastIndexOf(".")); // take off the extension
|
||||
const { default: JSZip } = await import("jszip");
|
||||
|
||||
const zip = new JSZip();
|
||||
|
@ -103,8 +103,7 @@ export default abstract class Exporter {
|
|||
for (const file of this.files) zip.file(filenameWithoutExt + "/" + file.name, file.blob);
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
|
||||
saveAs(content, filename);
|
||||
saveAs(content, filenameWithoutExt + ".zip");
|
||||
}
|
||||
|
||||
protected cleanUp(): string {
|
||||
|
|
|
@ -18,7 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
|
|||
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event";
|
||||
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
|
||||
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
|
||||
|
@ -59,27 +59,57 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean {
|
|||
return event?.getContent<LocalNotificationSettings>()?.is_silenced ?? false;
|
||||
}
|
||||
|
||||
export function clearAllNotifications(client: MatrixClient): Promise<Array<{}>> {
|
||||
const receiptPromises = client.getRooms().reduce((promises, room: Room) => {
|
||||
/**
|
||||
* Mark a room as read
|
||||
* @param room
|
||||
* @param client
|
||||
* @returns a promise that resolves when the room has been marked as read
|
||||
*/
|
||||
export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> {
|
||||
const roomEvents = room.getLiveTimeline().getEvents();
|
||||
const lastThreadEvents = room.lastThread?.events;
|
||||
|
||||
const lastRoomEvent = roomEvents?.[roomEvents?.length - 1];
|
||||
const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1];
|
||||
|
||||
const lastEvent =
|
||||
(lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadLastEvent;
|
||||
|
||||
try {
|
||||
if (lastEvent) {
|
||||
const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId)
|
||||
? ReceiptType.Read
|
||||
: ReceiptType.ReadPrivate;
|
||||
return await client.sendReadReceipt(lastEvent, receiptType, true);
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
} finally {
|
||||
// We've had a lot of stuck unread notifications that in e2ee rooms
|
||||
// They occur on event decryption when clients try to replicate the logic
|
||||
//
|
||||
// This resets the notification on a room, even though no read receipt
|
||||
// has been sent, particularly useful when the clients has incorrectly
|
||||
// notified a user.
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 0);
|
||||
for (const thread of room.getThreads()) {
|
||||
room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight, 0);
|
||||
room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Total, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all rooms with an unread counter as read
|
||||
* @param client The matrix client
|
||||
* @returns a promise that resolves when all rooms have been marked as read
|
||||
*/
|
||||
export function clearAllNotifications(client: MatrixClient): Promise<Array<{} | undefined>> {
|
||||
const receiptPromises = client.getRooms().reduce((promises: Array<Promise<{} | undefined>>, room: Room) => {
|
||||
if (room.getUnreadNotificationCount() > 0) {
|
||||
const roomEvents = room.getLiveTimeline().getEvents();
|
||||
const lastThreadEvents = room.lastThread?.events;
|
||||
|
||||
const lastRoomEvent = roomEvents?.[roomEvents?.length - 1];
|
||||
const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1];
|
||||
|
||||
const lastEvent =
|
||||
(lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0)
|
||||
? lastRoomEvent
|
||||
: lastThreadLastEvent;
|
||||
|
||||
if (lastEvent) {
|
||||
const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId)
|
||||
? ReceiptType.Read
|
||||
: ReceiptType.ReadPrivate;
|
||||
const promise = client.sendReadReceipt(lastEvent, receiptType, true);
|
||||
promises.push(promise);
|
||||
}
|
||||
const promise = clearRoomNotification(room, client);
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
return promises;
|
||||
|
|
|
@ -14,23 +14,23 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
VoiceBroadcastRecordingBody,
|
||||
VoiceBroadcastRecordingsStore,
|
||||
shouldDisplayAsVoiceBroadcastRecordingTile,
|
||||
VoiceBroadcastInfoEventType,
|
||||
VoiceBroadcastPlaybacksStore,
|
||||
VoiceBroadcastPlaybackBody,
|
||||
VoiceBroadcastInfoState,
|
||||
} from "..";
|
||||
import { IBodyProps } from "../../components/views/messages/IBodyProps";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
|
||||
export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
|
||||
const sdkContext = useContext(SDKContext);
|
||||
const client = MatrixClientPeg.get();
|
||||
const [infoState, setInfoState] = useState(mxEvent.getContent()?.state || VoiceBroadcastInfoState.Stopped);
|
||||
|
||||
|
@ -57,10 +57,10 @@ export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
|
|||
});
|
||||
|
||||
if (shouldDisplayAsVoiceBroadcastRecordingTile(infoState, client, mxEvent)) {
|
||||
const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client);
|
||||
const recording = sdkContext.voiceBroadcastRecordingsStore.getByInfoEvent(mxEvent, client);
|
||||
return <VoiceBroadcastRecordingBody recording={recording} />;
|
||||
}
|
||||
|
||||
const playback = VoiceBroadcastPlaybacksStore.instance().getByInfoEvent(mxEvent, client);
|
||||
const playback = sdkContext.voiceBroadcastPlaybacksStore.getByInfoEvent(mxEvent, client);
|
||||
return <VoiceBroadcastPlaybackBody playback={playback} />;
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ import classNames from "classnames";
|
|||
|
||||
import { LiveBadge, VoiceBroadcastLiveness } from "../..";
|
||||
import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
|
||||
import { Icon as MicrophoneIcon } from "../../../../res/img/voip/call-view/mic-on.svg";
|
||||
import { Icon as MicrophoneIcon } from "../../../../res/img/element-icons/mic.svg";
|
||||
import { Icon as TimerIcon } from "../../../../res/img/element-icons/Timer.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import RoomAvatar from "../../../components/views/avatars/RoomAvatar";
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { defer } from "matrix-js-sdk/src/utils";
|
||||
import React from "react";
|
||||
|
||||
import BaseDialog from "../../../components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "../../../components/views/elements/DialogButtons";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
|
||||
interface Props {
|
||||
onFinished: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
export const ConfirmListenBroadcastStopCurrentDialog: React.FC<Props> = ({ onFinished }) => {
|
||||
return (
|
||||
<BaseDialog title={_t("Listen to live broadcast?")} hasCancel={true} onFinished={onFinished}>
|
||||
<p>
|
||||
{_t(
|
||||
"If you start listening to this live broadcast, " +
|
||||
"your current live broadcast recording will be ended.",
|
||||
)}
|
||||
</p>
|
||||
<DialogButtons
|
||||
onPrimaryButtonClick={() => onFinished(true)}
|
||||
primaryButton={_t("Yes, end my recording")}
|
||||
cancelButton={_t("No")}
|
||||
onCancel={() => onFinished(false)}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const showConfirmListenBroadcastStopCurrentDialog = async (): Promise<boolean> => {
|
||||
const { promise, resolve } = defer<boolean>();
|
||||
|
||||
Modal.createDialog(ConfirmListenBroadcastStopCurrentDialog, {
|
||||
onFinished: resolve,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
|
@ -22,7 +22,7 @@ import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader";
|
|||
import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg";
|
||||
import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg";
|
||||
import { Icon as RecordIcon } from "../../../../res/img/element-icons/Record.svg";
|
||||
import { Icon as MicrophoneIcon } from "../../../../res/img/element-icons/Mic.svg";
|
||||
import { Icon as MicrophoneIcon } from "../../../../res/img/element-icons/mic.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection";
|
||||
import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu";
|
||||
|
|
|
@ -14,18 +14,34 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast";
|
||||
import { useTypedEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
|
||||
export const useHasRoomLiveVoiceBroadcast = (room: Room) => {
|
||||
const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(hasRoomLiveVoiceBroadcast(room).hasBroadcast);
|
||||
const sdkContext = useContext(SDKContext);
|
||||
const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(false);
|
||||
|
||||
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, () => {
|
||||
setHasLiveVoiceBroadcast(hasRoomLiveVoiceBroadcast(room).hasBroadcast);
|
||||
});
|
||||
const update = useMemo(() => {
|
||||
return sdkContext.client
|
||||
? () => {
|
||||
hasRoomLiveVoiceBroadcast(sdkContext.client!, room).then(
|
||||
({ hasBroadcast }) => {
|
||||
setHasLiveVoiceBroadcast(hasBroadcast);
|
||||
},
|
||||
() => {}, // no update on error
|
||||
);
|
||||
}
|
||||
: () => {}; // noop without client
|
||||
}, [room, sdkContext, setHasLiveVoiceBroadcast]);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
|
||||
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, () => update());
|
||||
return hasLiveVoiceBroadcast;
|
||||
};
|
||||
|
|
|
@ -30,6 +30,7 @@ export * from "./components/atoms/VoiceBroadcastControl";
|
|||
export * from "./components/atoms/VoiceBroadcastHeader";
|
||||
export * from "./components/atoms/VoiceBroadcastPlaybackControl";
|
||||
export * from "./components/atoms/VoiceBroadcastRoomSubtitle";
|
||||
export * from "./components/molecules/ConfirmListeBroadcastStopCurrent";
|
||||
export * from "./components/molecules/VoiceBroadcastPlaybackBody";
|
||||
export * from "./components/molecules/VoiceBroadcastSmallPlaybackBody";
|
||||
export * from "./components/molecules/VoiceBroadcastPreRecordingPip";
|
||||
|
@ -48,7 +49,9 @@ export * from "./utils/doMaybeSetCurrentVoiceBroadcastPlayback";
|
|||
export * from "./utils/getChunkLength";
|
||||
export * from "./utils/getMaxBroadcastLength";
|
||||
export * from "./utils/hasRoomLiveVoiceBroadcast";
|
||||
export * from "./utils/isVoiceBroadcastStartedEvent";
|
||||
export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice";
|
||||
export * from "./utils/retrieveStartedInfoEvent";
|
||||
export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile";
|
||||
export * from "./utils/shouldDisplayAsVoiceBroadcastTile";
|
||||
export * from "./utils/shouldDisplayAsVoiceBroadcastStoppedText";
|
||||
|
|
|
@ -31,7 +31,14 @@ import { PlaybackManager } from "../../audio/PlaybackManager";
|
|||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { MediaEventHelper } from "../../utils/MediaEventHelper";
|
||||
import { IDestroyable } from "../../utils/IDestroyable";
|
||||
import { VoiceBroadcastLiveness, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
|
||||
import {
|
||||
VoiceBroadcastLiveness,
|
||||
VoiceBroadcastInfoEventType,
|
||||
VoiceBroadcastInfoState,
|
||||
VoiceBroadcastInfoEventContent,
|
||||
VoiceBroadcastRecordingsStore,
|
||||
showConfirmListenBroadcastStopCurrentDialog,
|
||||
} from "..";
|
||||
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
|
||||
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
|
||||
import { determineVoiceBroadcastLiveness } from "../utils/determineVoiceBroadcastLiveness";
|
||||
|
@ -81,7 +88,7 @@ export class VoiceBroadcastPlayback
|
|||
public readonly liveData = new SimpleObservable<number[]>();
|
||||
private liveness: VoiceBroadcastLiveness = "not-live";
|
||||
|
||||
// set vial addInfoEvent() in constructor
|
||||
// set via addInfoEvent() in constructor
|
||||
private infoState!: VoiceBroadcastInfoState;
|
||||
private lastInfoEvent!: MatrixEvent;
|
||||
|
||||
|
@ -89,7 +96,11 @@ export class VoiceBroadcastPlayback
|
|||
private chunkRelationHelper!: RelationsHelper;
|
||||
private infoRelationHelper!: RelationsHelper;
|
||||
|
||||
public constructor(public readonly infoEvent: MatrixEvent, private client: MatrixClient) {
|
||||
public constructor(
|
||||
public readonly infoEvent: MatrixEvent,
|
||||
private client: MatrixClient,
|
||||
private recordings: VoiceBroadcastRecordingsStore,
|
||||
) {
|
||||
super();
|
||||
this.addInfoEvent(this.infoEvent);
|
||||
this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
|
@ -151,12 +162,20 @@ export class VoiceBroadcastPlayback
|
|||
this.setDuration(this.chunkEvents.getLength());
|
||||
|
||||
if (this.getState() === VoiceBroadcastPlaybackState.Buffering) {
|
||||
await this.start();
|
||||
await this.startOrPlayNext();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private startOrPlayNext = async (): Promise<void> => {
|
||||
if (this.currentlyPlaying) {
|
||||
return this.playNext();
|
||||
}
|
||||
|
||||
return await this.start();
|
||||
};
|
||||
|
||||
private addInfoEvent = (event: MatrixEvent): void => {
|
||||
if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) {
|
||||
// Only handle newer events
|
||||
|
@ -263,7 +282,10 @@ export class VoiceBroadcastPlayback
|
|||
return this.playEvent(next);
|
||||
}
|
||||
|
||||
if (this.getInfoState() === VoiceBroadcastInfoState.Stopped) {
|
||||
if (
|
||||
this.getInfoState() === VoiceBroadcastInfoState.Stopped &&
|
||||
this.chunkEvents.getSequenceForEvent(this.currentlyPlaying) === this.lastChunkSequence
|
||||
) {
|
||||
this.stop();
|
||||
} else {
|
||||
// No more chunks available, although the broadcast is not finished → enter buffering state.
|
||||
|
@ -271,6 +293,17 @@ export class VoiceBroadcastPlayback
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number} The last chunk sequence from the latest info event.
|
||||
* Falls back to the length of received chunks if the info event does not provide the number.
|
||||
*/
|
||||
private get lastChunkSequence(): number {
|
||||
return (
|
||||
this.lastInfoEvent.getContent<VoiceBroadcastInfoEventContent>()?.last_chunk_sequence ||
|
||||
this.chunkEvents.getNumberOfEvents()
|
||||
);
|
||||
}
|
||||
|
||||
private async playEvent(event: MatrixEvent): Promise<void> {
|
||||
this.setState(VoiceBroadcastPlaybackState.Playing);
|
||||
this.currentlyPlaying = event;
|
||||
|
@ -372,6 +405,21 @@ export class VoiceBroadcastPlayback
|
|||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.state === VoiceBroadcastPlaybackState.Playing) return;
|
||||
|
||||
const currentRecording = this.recordings.getCurrent();
|
||||
|
||||
if (currentRecording && currentRecording.getState() !== VoiceBroadcastInfoState.Stopped) {
|
||||
const shouldStopRecording = await showConfirmListenBroadcastStopCurrentDialog();
|
||||
|
||||
if (!shouldStopRecording) {
|
||||
// keep recording
|
||||
return;
|
||||
}
|
||||
|
||||
await this.recordings.getCurrent()?.stop();
|
||||
}
|
||||
|
||||
const chunkEvents = this.chunkEvents.getEvents();
|
||||
|
||||
const toPlay =
|
||||
|
|
|
@ -60,13 +60,20 @@ export class VoiceBroadcastRecording
|
|||
{
|
||||
private state: VoiceBroadcastInfoState;
|
||||
private recorder: VoiceBroadcastRecorder;
|
||||
private sequence = 1;
|
||||
private dispatcherRef: string;
|
||||
private chunkEvents = new VoiceBroadcastChunkEvents();
|
||||
private chunkRelationHelper: RelationsHelper;
|
||||
private maxLength: number;
|
||||
private timeLeft: number;
|
||||
|
||||
/**
|
||||
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
|
||||
* This variable holds the last sequence number.
|
||||
* Starts with 0 because there is no chunk at the beginning of a broadcast.
|
||||
* Will be incremented when a chunk message is created.
|
||||
*/
|
||||
private sequence = 0;
|
||||
|
||||
public constructor(
|
||||
public readonly infoEvent: MatrixEvent,
|
||||
private client: MatrixClient,
|
||||
|
@ -268,7 +275,8 @@ export class VoiceBroadcastRecording
|
|||
event_id: this.infoEvent.getId(),
|
||||
};
|
||||
content["io.element.voice_broadcast_chunk"] = {
|
||||
sequence: this.sequence++,
|
||||
/** Increment the last sequence number and use it for this message. Also see {@link sequence}. */
|
||||
sequence: ++this.sequence,
|
||||
};
|
||||
|
||||
await this.client.sendMessage(this.infoEvent.getRoomId(), content);
|
||||
|
|
|
@ -17,7 +17,12 @@ limitations under the License.
|
|||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
|
||||
|
||||
import { VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState } from "..";
|
||||
import {
|
||||
VoiceBroadcastPlayback,
|
||||
VoiceBroadcastPlaybackEvent,
|
||||
VoiceBroadcastPlaybackState,
|
||||
VoiceBroadcastRecordingsStore,
|
||||
} from "..";
|
||||
import { IDestroyable } from "../../utils/IDestroyable";
|
||||
|
||||
export enum VoiceBroadcastPlaybacksStoreEvent {
|
||||
|
@ -37,12 +42,12 @@ export class VoiceBroadcastPlaybacksStore
|
|||
extends TypedEventEmitter<VoiceBroadcastPlaybacksStoreEvent, EventMap>
|
||||
implements IDestroyable
|
||||
{
|
||||
private current: VoiceBroadcastPlayback | null;
|
||||
private current: VoiceBroadcastPlayback | null = null;
|
||||
|
||||
/** Playbacks indexed by their info event id. */
|
||||
private playbacks = new Map<string, VoiceBroadcastPlayback>();
|
||||
|
||||
public constructor() {
|
||||
public constructor(private recordings: VoiceBroadcastRecordingsStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
@ -69,7 +74,7 @@ export class VoiceBroadcastPlaybacksStore
|
|||
const infoEventId = infoEvent.getId();
|
||||
|
||||
if (!this.playbacks.has(infoEventId)) {
|
||||
this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client));
|
||||
this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client, this.recordings));
|
||||
}
|
||||
|
||||
return this.playbacks.get(infoEventId);
|
||||
|
@ -114,13 +119,4 @@ export class VoiceBroadcastPlaybacksStore
|
|||
|
||||
this.playbacks = new Map();
|
||||
}
|
||||
|
||||
public static readonly _instance = new VoiceBroadcastPlaybacksStore();
|
||||
|
||||
/**
|
||||
* TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged
|
||||
*/
|
||||
public static instance() {
|
||||
return VoiceBroadcastPlaybacksStore._instance;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ export enum VoiceBroadcastRecordingsStoreEvent {
|
|||
}
|
||||
|
||||
interface EventMap {
|
||||
[VoiceBroadcastRecordingsStoreEvent.CurrentChanged]: (recording: VoiceBroadcastRecording) => void;
|
||||
[VoiceBroadcastRecordingsStoreEvent.CurrentChanged]: (recording: VoiceBroadcastRecording | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -41,17 +41,23 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
|
|||
public setCurrent(current: VoiceBroadcastRecording): void {
|
||||
if (this.current === current) return;
|
||||
|
||||
const infoEventId = current.infoEvent.getId();
|
||||
|
||||
if (!infoEventId) {
|
||||
throw new Error("Got broadcast info event without Id");
|
||||
}
|
||||
|
||||
if (this.current) {
|
||||
this.current.off(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged);
|
||||
}
|
||||
|
||||
this.current = current;
|
||||
this.current.on(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged);
|
||||
this.recordings.set(current.infoEvent.getId(), current);
|
||||
this.recordings.set(infoEventId, current);
|
||||
this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current);
|
||||
}
|
||||
|
||||
public getCurrent(): VoiceBroadcastRecording {
|
||||
public getCurrent(): VoiceBroadcastRecording | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
|
@ -70,11 +76,13 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
|
|||
public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording {
|
||||
const infoEventId = infoEvent.getId();
|
||||
|
||||
if (!this.recordings.has(infoEventId)) {
|
||||
this.recordings.set(infoEventId, new VoiceBroadcastRecording(infoEvent, client));
|
||||
if (!infoEventId) {
|
||||
throw new Error("Got broadcast info event without Id");
|
||||
}
|
||||
|
||||
return this.recordings.get(infoEventId);
|
||||
const recording = this.recordings.get(infoEventId) || new VoiceBroadcastRecording(infoEvent, client);
|
||||
this.recordings.set(infoEventId, recording);
|
||||
return recording;
|
||||
}
|
||||
|
||||
private onCurrentStateChanged = (state: VoiceBroadcastInfoState) => {
|
||||
|
@ -82,13 +90,4 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
|
|||
this.clearCurrent();
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly cachedInstance = new VoiceBroadcastRecordingsStore();
|
||||
|
||||
/**
|
||||
* TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged
|
||||
*/
|
||||
public static instance(): VoiceBroadcastRecordingsStore {
|
||||
return VoiceBroadcastRecordingsStore.cachedInstance;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,6 +97,19 @@ export class VoiceBroadcastChunkEvents {
|
|||
return this.events.indexOf(event) >= this.events.length - 1;
|
||||
}
|
||||
|
||||
public getSequenceForEvent(event: MatrixEvent): number | null {
|
||||
const sequence = parseInt(event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10);
|
||||
if (!isNaN(sequence)) return sequence;
|
||||
|
||||
if (this.events.includes(event)) return this.events.indexOf(event) + 1;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public getNumberOfEvents(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
|
||||
private calculateChunkLength(event: MatrixEvent): number {
|
||||
return event.getContent()?.["org.matrix.msc1767.audio"]?.duration || event.getContent()?.info?.duration || 0;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
|
||||
import { hasRoomLiveVoiceBroadcast, VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore } from "..";
|
||||
import InfoDialog from "../../components/views/dialogs/InfoDialog";
|
||||
|
@ -67,11 +68,19 @@ const showOthersAlreadyRecordingDialog = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const checkVoiceBroadcastPreConditions = (
|
||||
const showNoConnectionDialog = (): void => {
|
||||
Modal.createDialog(InfoDialog, {
|
||||
title: _t("Connection error"),
|
||||
description: <p>{_t("Unfortunately we're unable to start a recording right now. Please try again later.")}</p>,
|
||||
hasCloseButton: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const checkVoiceBroadcastPreConditions = async (
|
||||
room: Room,
|
||||
client: MatrixClient,
|
||||
recordingsStore: VoiceBroadcastRecordingsStore,
|
||||
): boolean => {
|
||||
): Promise<boolean> => {
|
||||
if (recordingsStore.getCurrent()) {
|
||||
showAlreadyRecordingDialog();
|
||||
return false;
|
||||
|
@ -86,7 +95,12 @@ export const checkVoiceBroadcastPreConditions = (
|
|||
return false;
|
||||
}
|
||||
|
||||
const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId);
|
||||
if (client.getSyncState() === SyncState.Error) {
|
||||
showNoConnectionDialog();
|
||||
return false;
|
||||
}
|
||||
|
||||
const { hasBroadcast, startedByUser } = await hasRoomLiveVoiceBroadcast(client, room, currentUserId);
|
||||
|
||||
if (hasBroadcast && startedByUser) {
|
||||
showAlreadyRecordingDialog();
|
||||
|
|
|
@ -34,12 +34,12 @@ import {
|
|||
* @param {VoiceBroadcastPlaybacksStore} voiceBroadcastPlaybacksStore
|
||||
* @param {VoiceBroadcastRecordingsStore} voiceBroadcastRecordingsStore
|
||||
*/
|
||||
export const doMaybeSetCurrentVoiceBroadcastPlayback = (
|
||||
export const doMaybeSetCurrentVoiceBroadcastPlayback = async (
|
||||
room: Room,
|
||||
client: MatrixClient,
|
||||
voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore,
|
||||
voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore,
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
// do not disturb the current recording
|
||||
if (voiceBroadcastRecordingsStore.hasCurrent()) return;
|
||||
|
||||
|
@ -50,7 +50,7 @@ export const doMaybeSetCurrentVoiceBroadcastPlayback = (
|
|||
return;
|
||||
}
|
||||
|
||||
const { infoEvent } = hasRoomLiveVoiceBroadcast(room);
|
||||
const { infoEvent } = await hasRoomLiveVoiceBroadcast(client, room);
|
||||
|
||||
if (infoEvent) {
|
||||
// live broadcast in the room + no recording + not listening yet: set the current broadcast
|
||||
|
|
|
@ -15,13 +15,17 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
|
||||
import { Features } from "../../settings/Settings";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
/**
|
||||
* Returns the target chunk length for voice broadcasts:
|
||||
* - Tries to get the value from the voice_broadcast.chunk_length config
|
||||
* - If {@see Features.VoiceBroadcastForceSmallChunks} is enabled uses 15s chunk length
|
||||
* - Otherwise to get the value from the voice_broadcast.chunk_length config
|
||||
* - If that fails from DEFAULTS
|
||||
* - If that fails fall back to 120 (two minutes)
|
||||
*/
|
||||
export const getChunkLength = (): number => {
|
||||
if (SettingsStore.getValue(Features.VoiceBroadcastForceSmallChunks)) return 15;
|
||||
return SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast?.chunk_length || 120;
|
||||
};
|
||||
|
|
|
@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
|
||||
import { retrieveStartedInfoEvent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
|
||||
import { asyncEvery } from "../../utils/arrays";
|
||||
|
||||
interface Result {
|
||||
// whether there is a live broadcast in the room
|
||||
|
@ -27,22 +28,26 @@ interface Result {
|
|||
startedByUser: boolean;
|
||||
}
|
||||
|
||||
export const hasRoomLiveVoiceBroadcast = (room: Room, userId?: string): Result => {
|
||||
export const hasRoomLiveVoiceBroadcast = async (client: MatrixClient, room: Room, userId?: string): Promise<Result> => {
|
||||
let hasBroadcast = false;
|
||||
let startedByUser = false;
|
||||
let infoEvent: MatrixEvent | null = null;
|
||||
|
||||
const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType);
|
||||
stateEvents.every((event: MatrixEvent) => {
|
||||
await asyncEvery(stateEvents, async (event: MatrixEvent) => {
|
||||
const state = event.getContent()?.state;
|
||||
|
||||
if (state && state !== VoiceBroadcastInfoState.Stopped) {
|
||||
const startEvent = await retrieveStartedInfoEvent(event, client);
|
||||
|
||||
// skip if started voice broadcast event is redacted
|
||||
if (startEvent?.isRedacted()) return true;
|
||||
|
||||
hasBroadcast = true;
|
||||
infoEvent = event;
|
||||
infoEvent = startEvent;
|
||||
|
||||
// state key = sender's MXID
|
||||
if (event.getStateKey() === userId) {
|
||||
infoEvent = event;
|
||||
startedByUser = true;
|
||||
// break here, because more than true / true is not possible
|
||||
return false;
|
||||
|
|
|
@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function htmlToPlainText(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html").documentElement.textContent;
|
||||
}
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../types";
|
||||
|
||||
export const isVoiceBroadcastStartedEvent = (event: MatrixEvent): boolean => {
|
||||
return (
|
||||
event.getType() === VoiceBroadcastInfoEventType && event.getContent()?.state === VoiceBroadcastInfoState.Started
|
||||
);
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VoiceBroadcastPlaybacksStore } from "..";
|
||||
|
||||
export const pauseNonLiveBroadcastFromOtherRoom = (
|
||||
room: Room,
|
||||
voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore,
|
||||
): void => {
|
||||
const playingBroadcast = voiceBroadcastPlaybacksStore.getCurrent();
|
||||
|
||||
if (
|
||||
!playingBroadcast ||
|
||||
playingBroadcast?.getLiveness() === "live" ||
|
||||
playingBroadcast?.infoEvent.getRoomId() === room.roomId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
voiceBroadcastPlaybacksStore.clearCurrent();
|
||||
playingBroadcast.pause();
|
||||
};
|
45
src/voice-broadcast/utils/retrieveStartedInfoEvent.ts
Normal file
45
src/voice-broadcast/utils/retrieveStartedInfoEvent.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VoiceBroadcastInfoState } from "..";
|
||||
|
||||
export const retrieveStartedInfoEvent = async (
|
||||
event: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
): Promise<MatrixEvent | null> => {
|
||||
// started event passed as argument
|
||||
if (event.getContent()?.state === VoiceBroadcastInfoState.Started) return event;
|
||||
|
||||
const relatedEventId = event.getRelation()?.event_id;
|
||||
|
||||
// no related event
|
||||
if (!relatedEventId) return null;
|
||||
|
||||
const roomId = event.getRoomId() || "";
|
||||
const relatedEventFromRoom = client.getRoom(roomId)?.findEventById(relatedEventId);
|
||||
|
||||
// event found
|
||||
if (relatedEventFromRoom) return relatedEventFromRoom;
|
||||
|
||||
try {
|
||||
const relatedEventData = await client.fetchRoomEvent(roomId, relatedEventId);
|
||||
return new MatrixEvent(relatedEventData);
|
||||
} catch (e) {}
|
||||
|
||||
return null;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue