Merge branch 'develop' into andybalaam/stas-demydiuk-membership-type3

This commit is contained in:
Andy Balaam
2024-03-20 17:25:23 +00:00
124 changed files with 2399 additions and 1052 deletions

View File

@@ -16,12 +16,7 @@ limitations under the License.
import { JSXElementConstructor } from "react";
export type { NonEmptyArray } from "matrix-js-sdk/src/matrix";
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type { NonEmptyArray, XOR, Writeable } from "matrix-js-sdk/src/matrix";
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;

View File

@@ -151,16 +151,10 @@ declare global {
interface HTMLAudioElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string): void;
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string): void;
}
// Add Chrome-specific `instant` ScrollBehaviour

View File

@@ -14,14 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { BLURHASH_FIELD } from "../utils/image-media";
import type { IWidget } from "matrix-widget-api";
import type { BLURHASH_FIELD } from "../utils/image-media";
import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types";
import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types";
import type { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType } from "../voice-broadcast/types";
// Matrix JS SDK extensions
declare module "matrix-js-sdk" {
declare module "matrix-js-sdk/src/types" {
export interface FileInfo {
/**
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/2448
*/
[BLURHASH_FIELD]?: string;
}
export interface StateEvents {
// Jitsi-backed video room state events
[JitsiCallMemberEventType]: JitsiCallMemberContent;
// Unstable widgets state events
"im.vector.modular.widgets": IWidget | {};
[WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent;
// Unstable voice broadcast state events
[VoiceBroadcastInfoEventType]: VoiceBroadcastInfoEventContent;
// Element custom state events
"im.vector.web.settings": Record<string, any>;
"org.matrix.room.preview_urls": { disable: boolean };
// XXX unspecced usages of `m.room.*` events
"m.room.plumbing": {
status: string;
};
"m.room.bot.options": unknown;
}
}

View File

@@ -29,6 +29,7 @@ import { getUnsentMessages } from "./components/structures/RoomStatusBar";
import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread";
import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership";
import SettingsStore from "./settings/SettingsStore";
import { getMarkedUnreadState } from "./utils/notifications";
export enum RoomNotifState {
AllMessagesLoud = "all_messages_loud",
@@ -279,7 +280,8 @@ export function determineUnreadState(
return { symbol: null, count: trueCount, level: NotificationLevel.Highlight };
}
if (greyNotifs > 0) {
const markedUnreadState = getMarkedUnreadState(room);
if (greyNotifs > 0 || markedUnreadState) {
return { symbol: null, count: trueCount, level: NotificationLevel.Notification };
}

View File

@@ -291,7 +291,7 @@ Response:
*/
import { IContent, MatrixEvent, IEvent } from "matrix-js-sdk/src/matrix";
import { IContent, MatrixEvent, IEvent, StateEvents } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
@@ -725,7 +725,7 @@ async function getOpenIdToken(event: MessageEvent<any>): Promise<void> {
async function sendEvent(
event: MessageEvent<{
type: string;
type: keyof StateEvents;
state_key?: string;
content?: IContent;
}>,

View File

@@ -18,9 +18,9 @@ limitations under the License.
*/
import * as React from "react";
import { User, IContent, Direction, ContentHelpers, MRoomTopicEventContent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { ContentHelpers, Direction, EventType, IContent, MRoomTopicEventContent, User } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, RoomMemberEventContent } from "matrix-js-sdk/src/types";
import dis from "./dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "./languageHandler";
@@ -240,12 +240,12 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId());
const content = {
const ev = cli.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomMember, cli.getSafeUserId());
const content: RoomMemberEventContent = {
...(ev ? ev.getContent() : { membership: KnownMembership.Join }),
displayname: args,
};
return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getSafeUserId()));
return success(cli.sendStateEvent(roomId, EventType.RoomMember, content, cli.getSafeUserId()));
}
return reject(this.getUsage());
},
@@ -266,7 +266,7 @@ export const Commands = [
return success(
promise.then((url) => {
if (!url) return;
return cli.sendStateEvent(roomId, "m.room.avatar", { url }, "");
return cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, "");
}),
);
},
@@ -290,12 +290,12 @@ export const Commands = [
return success(
promise.then((url) => {
if (!url) return;
const ev = room?.currentState.getStateEvents("m.room.member", userId);
const content = {
const ev = room?.currentState.getStateEvents(EventType.RoomMember, userId);
const content: RoomMemberEventContent = {
...(ev ? ev.getContent() : { membership: KnownMembership.Join }),
avatar_url: url,
};
return cli.sendStateEvent(roomId, "m.room.member", content, userId);
return cli.sendStateEvent(roomId, EventType.RoomMember, content, userId);
}),
);
},

View File

@@ -17,3 +17,12 @@ limitations under the License.
// Event type for room account data and room creation content used to mark rooms as virtual rooms
// (and store the ID of their native room)
export const VIRTUAL_ROOM_EVENT_TYPE = "im.vector.is_virtual_room";
export const JitsiCallMemberEventType = "io.element.video.member";
export interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}

View File

@@ -47,6 +47,7 @@ import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import classNames from "classnames";
import { sortBy, uniqBy } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { SpaceChildEventContent } from "matrix-js-sdk/src/types";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
@@ -727,7 +728,7 @@ const ManageButtons: React.FC<IManageButtonsProps> = ({ hierarchy, selected, set
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
const content: SpaceChildEventContent = {
...existingContent,
suggested: !selectionAllSuggested,
};

View File

@@ -1046,7 +1046,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
if (
!(await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) ||
!(await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) &&
!(await client.isVersionSupported("v1.4"))
) {
logger.warn(

View File

@@ -20,7 +20,7 @@ import { Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/matrix
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SmartMarker from "../location/SmartMarker";
import { SmartMarker } from "../location";
interface Props {
map: maplibregl.Map;

View File

@@ -36,7 +36,7 @@ import MapFallback from "../location/MapFallback";
import { MapError } from "../location/MapError";
import { LocationShareError } from "../../../utils/location";
interface IProps {
export interface IProps {
roomId: Room["roomId"];
matrixClient: MatrixClient;
// open the map centered on this beacon's location

View File

@@ -0,0 +1,31 @@
/*
Copyright 2024 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.
*/
// Exports beacon components which touch maplibre-gs wrapped in React Suspense to enable code splitting
import React, { ComponentProps, lazy, Suspense } from "react";
import Spinner from "../elements/Spinner";
const BeaconViewDialogComponent = lazy(() => import("./BeaconViewDialog"));
export function BeaconViewDialog(props: ComponentProps<typeof BeaconViewDialogComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<BeaconViewDialogComponent {...props} />
</Suspense>
);
}

View File

@@ -30,7 +30,7 @@ import { NotificationLevel } from "../../../stores/notifications/NotificationLev
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 { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
@@ -45,13 +45,60 @@ import { useSettingValue } from "../../../hooks/useSettings";
export interface RoomGeneralContextMenuProps extends IContextMenuProps {
room: Room;
/**
* Called when the 'favourite' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostFavoriteClick?: (event: ButtonEvent) => void;
/**
* Called when the 'low priority' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostLowPriorityClick?: (event: ButtonEvent) => void;
/**
* Called when the 'invite' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostInviteClick?: (event: ButtonEvent) => void;
/**
* Called when the 'copy link' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostCopyLinkClick?: (event: ButtonEvent) => void;
/**
* Called when the 'settings' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostSettingsClick?: (event: ButtonEvent) => void;
/**
* Called when the 'forget room' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostForgetClick?: (event: ButtonEvent) => void;
/**
* Called when the 'leave' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostLeaveClick?: (event: ButtonEvent) => void;
/**
* Called when the 'mark as read' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostMarkAsReadClick?: (event: ButtonEvent) => void;
/**
* Called when the 'mark as unread' option is selected, after the menu has processed
* the mouse or keyboard event.
* @param event The event that caused the option to be selected.
*/
onPostMarkAsUnreadClick?: (event: ButtonEvent) => void;
}
/**
@@ -67,6 +114,8 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
onPostSettingsClick,
onPostLeaveClick,
onPostForgetClick,
onPostMarkAsReadClick,
onPostMarkAsUnreadClick,
...props
}) => {
const cli = useContext(MatrixClientContext);
@@ -213,18 +262,33 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
}
const { level } = useUnreadNotifications(room);
const markAsReadOption: JSX.Element | null =
level > NotificationLevel.None ? (
<IconizedContextMenuCheckbox
onClick={() => {
clearRoomNotification(room, cli);
onFinished?.();
}}
active={false}
label={_t("room|context_menu|mark_read")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
) : null;
const markAsReadOption: JSX.Element | null = (() => {
if (level > NotificationLevel.None) {
return (
<IconizedContextMenuOption
onClick={wrapHandler(() => {
clearRoomNotification(room, cli);
onFinished?.();
}, onPostMarkAsReadClick)}
label={_t("room|context_menu|mark_read")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
);
} else if (!roomTags.includes(DefaultTagID.Archived)) {
return (
<IconizedContextMenuOption
onClick={wrapHandler(() => {
setMarkedUnreadState(room, cli, true);
onFinished?.();
}, onPostMarkAsUnreadClick)}
label={_t("room|context_menu|mark_unread")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsUnread"
/>
);
} else {
return null;
}
})();
const developerModeEnabled = useSettingValue<boolean>("developerMode");
const developerToolsOption = developerModeEnabled ? (

View File

@@ -60,7 +60,7 @@ const BaseTool: React.FC<XOR<IMinProps, IProps>> = ({
let actionButton: ReactNode = null;
if (message) {
children = message;
} else if (onAction) {
} else if (onAction && actionLabel) {
const onActionClick = (): void => {
onAction().then((msg) => {
if (typeof msg === "string") {

View File

@@ -38,7 +38,7 @@ export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) =>
);
const onSend = async ([eventType, stateKey]: string[], content: IContent): Promise<void> => {
await cli.sendStateEvent(context.room.roomId, eventType, content, stateKey);
await cli.sendStateEvent(context.room.roomId, eventType as any, content, stateKey);
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;

View File

@@ -430,7 +430,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
onPrimaryButtonClick={this.onRecoveryKeyNext}
hasCancel={true}
cancelButton={_t("action|go_back")}
cancelButtonClass="danger"
cancelButtonClass="warning"
onCancel={this.onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}

View File

@@ -16,22 +16,23 @@ limitations under the License.
*/
import React from "react";
import hljs from "highlight.js";
interface IProps {
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
interface Props {
language?: string;
children: string;
}
export default class SyntaxHighlight extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const { children: content, language } = this.props;
const highlighted = language ? hljs.highlight(content, { language }) : hljs.highlightAuto(content);
export default function SyntaxHighlight({ children, language }: Props): JSX.Element {
const highlighted = useAsyncMemo(async () => {
const { default: highlight } = await import("highlight.js");
return language ? highlight.highlight(children, { language }) : highlight.highlightAuto(children);
}, [language, children]);
return (
<pre className={`mx_SyntaxHighlight hljs language-${highlighted.language}`}>
<code dangerouslySetInnerHTML={{ __html: highlighted.value }} />
</pre>
);
}
return (
<pre className={`mx_SyntaxHighlight hljs language-${highlighted?.language}`}>
{highlighted ? <code dangerouslySetInnerHTML={{ __html: highlighted.value }} /> : children}
</pre>
);
}

View File

@@ -24,7 +24,7 @@ import { aboveLeftOf, useContextMenu, MenuProps } from "../../structures/Context
import { OverflowMenuContext } from "../rooms/MessageComposerButtons";
import LocationShareMenu from "./LocationShareMenu";
interface IProps {
export interface IProps {
roomId: string;
sender: RoomMember;
menuPosition?: MenuProps;

View File

@@ -139,7 +139,7 @@ const onGeolocateError = (e: GeolocationPositionError): void => {
});
};
interface MapProps {
export interface MapProps {
id: string;
interactive?: boolean;
/**

View File

@@ -18,7 +18,8 @@ import React, { ReactNode, useCallback, useEffect, useState } from "react";
import * as maplibregl from "maplibre-gl";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { createMarker, parseGeoUri } from "../../../utils/location";
import { parseGeoUri } from "../../../utils/location";
import { createMarker } from "../../../utils/location/map";
import Marker from "./Marker";
const useMapMarker = (
@@ -66,7 +67,7 @@ const useMapMarker = (
};
};
interface SmartMarkerProps {
export interface SmartMarkerProps {
map: maplibregl.Map;
geoUri: string;
id?: string;

View File

@@ -0,0 +1,71 @@
/*
Copyright 2024 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.
*/
// Exports location components which touch maplibre-gs wrapped in React Suspense to enable code splitting
import React, { ComponentProps, lazy, Suspense } from "react";
import Spinner from "../elements/Spinner";
const MapComponent = lazy(() => import("./Map"));
export function Map(props: ComponentProps<typeof MapComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<MapComponent {...props} />
</Suspense>
);
}
const LocationPickerComponent = lazy(() => import("./LocationPicker"));
export function LocationPicker(props: ComponentProps<typeof LocationPickerComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<LocationPickerComponent {...props} />
</Suspense>
);
}
const SmartMarkerComponent = lazy(() => import("./SmartMarker"));
export function SmartMarker(props: ComponentProps<typeof SmartMarkerComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<SmartMarkerComponent {...props} />
</Suspense>
);
}
const LocationButtonComponent = lazy(() => import("./LocationButton"));
export function LocationButton(props: ComponentProps<typeof LocationButtonComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<LocationButtonComponent {...props} />
</Suspense>
);
}
const LocationViewDialogComponent = lazy(() => import("./LocationViewDialog"));
export function LocationViewDialog(props: ComponentProps<typeof LocationViewDialogComponent>): JSX.Element {
return (
<Suspense fallback={<Spinner />}>
<LocationViewDialogComponent {...props} />
</Suspense>
);
}

View File

@@ -38,12 +38,11 @@ import { isSelfLocation, LocationShareError } from "../../../utils/location";
import { BeaconDisplayStatus, getBeaconDisplayStatus } from "../beacon/displayStatus";
import BeaconStatus from "../beacon/BeaconStatus";
import OwnBeaconStatus from "../beacon/OwnBeaconStatus";
import Map from "../location/Map";
import { Map, SmartMarker } from "../location";
import { MapError } from "../location/MapError";
import MapFallback from "../location/MapFallback";
import SmartMarker from "../location/SmartMarker";
import { GetRelationsForEvent } from "../rooms/EventTile";
import BeaconViewDialog from "../beacon/BeaconViewDialog";
import { BeaconViewDialog } from "../beacon";
import { IBodyProps } from "./IBodyProps";
const useBeaconState = (

View File

@@ -29,9 +29,7 @@ import {
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import TooltipTarget from "../elements/TooltipTarget";
import { Alignment } from "../elements/Tooltip";
import LocationViewDialog from "../location/LocationViewDialog";
import Map from "../location/Map";
import SmartMarker from "../location/SmartMarker";
import { SmartMarker, Map, LocationViewDialog } from "../location";
import { IBodyProps } from "./IBodyProps";
import { createReconnectedListener } from "../../../utils/connection";

View File

@@ -16,7 +16,6 @@ limitations under the License.
import React, { createRef, SyntheticEvent, MouseEvent } from "react";
import ReactDOM from "react-dom";
import highlight from "highlight.js";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -238,7 +237,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
pre.append(document.createElement("span"));
}
private highlightCode(code: HTMLElement): void {
private async highlightCode(code: HTMLElement): Promise<void> {
const { default: highlight } = await import("highlight.js");
if (code.textContent && code.textContent.length > MAX_HIGHLIGHT_LENGTH) {
console.log(
"Code block is bigger than highlight limit (" +

View File

@@ -15,8 +15,9 @@ limitations under the License.
*/
import React, { ChangeEvent, ContextType, createRef, SyntheticEvent } from "react";
import { IContent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomCanonicalAliasEventContent } from "matrix-js-sdk/src/types";
import EditableItemList from "../elements/EditableItemList";
import { _t } from "../../../languageHandler";
@@ -169,7 +170,7 @@ export default class AliasSettings extends React.Component<IProps, IState> {
updatingCanonicalAlias: true,
});
const eventContent: IContent = {
const eventContent: RoomCanonicalAliasEventContent = {
alt_aliases: this.state.altAliases,
};
@@ -197,7 +198,7 @@ export default class AliasSettings extends React.Component<IProps, IState> {
updatingCanonicalAlias: true,
});
const eventContent: IContent = {};
const eventContent: RoomCanonicalAliasEventContent = {};
if (this.state.canonicalAlias) {
eventContent["alias"] = this.state.canonicalAlias;

View File

@@ -24,7 +24,7 @@ import { CollapsibleButton } from "./CollapsibleButton";
import { MenuProps } from "../../structures/ContextMenu";
import dis from "../../../dispatcher/dispatcher";
import ErrorDialog from "../dialogs/ErrorDialog";
import LocationButton from "../location/LocationButton";
import { LocationButton } from "../location";
import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";

View File

@@ -102,7 +102,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
if (notification.isIdle && !notification.knocked) return null;
if (hideIfDot && notification.level < NotificationLevel.Notification) {
// This would just be a dot and we've been told not to show dots, so don't show it
if (!notification.hasUnreadCount) return null;
return null;
}
const commonProps: React.ComponentProps<typeof StatelessNotificationBadge> = {

View File

@@ -70,6 +70,16 @@ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props,
symbol = formatCount(count);
}
// We show a dot if either:
// * The props force us to, or
// * It's just an activity-level notification or (in theory) lower and the room isn't knocked
const badgeType =
forceDot || (level <= NotificationLevel.Activity && !knocked)
? "dot"
: !symbol || symbol.length < 3
? "badge_2char"
: "badge_3char";
const classes = classNames({
mx_NotificationBadge: true,
mx_NotificationBadge_visible: isEmptyBadge || knocked ? true : hasUnreadCount,
@@ -77,10 +87,10 @@ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props,
mx_NotificationBadge_level_highlight: level >= NotificationLevel.Highlight,
mx_NotificationBadge_knocked: knocked,
// At most one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char
mx_NotificationBadge_dot: (isEmptyBadge && !knocked) || forceDot,
mx_NotificationBadge_2char: !forceDot && symbol && symbol.length > 0 && symbol.length < 3,
mx_NotificationBadge_3char: !forceDot && symbol && symbol.length > 2,
// Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char
mx_NotificationBadge_dot: badgeType === "dot",
mx_NotificationBadge_2char: badgeType === "badge_2char",
mx_NotificationBadge_3char: badgeType === "badge_3char",
});
if (props.onClick) {

View File

@@ -363,6 +363,12 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
onPostLeaveClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
}
onPostMarkAsReadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev)
}
onPostMarkAsUnreadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev)
}
/>
)}
</React.Fragment>

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent, Room, RoomStateEvent, EventType } from "matrix-js-sdk/src/matrix";
import { EventType, MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Button, Text } from "@vector-im/compound-web";
@@ -101,7 +101,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
public onKickClick = (): void => {
MatrixClientPeg.safeGet()
.sendStateEvent(this.state.roomId, "m.room.third_party_invite", {}, this.state.stateKey)
.sendStateEvent(this.state.roomId, EventType.RoomThirdPartyInvite, {}, this.state.stateKey)
.catch((err) => {
logger.error(err);

View File

@@ -0,0 +1,142 @@
/*
*
* Copyright 2024 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, { useState, JSX, PropsWithChildren } from "react";
import { Button } from "@vector-im/compound-web";
import { compare } from "matrix-js-sdk/src/utils";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import PowerSelector from "../elements/PowerSelector";
import { _t } from "../../../languageHandler";
import SettingsFieldset from "./SettingsFieldset";
/**
* Display in a fieldset, the power level of the users and allow to change them.
* The apply button is disabled until the power level of an user is changed.
* If there is no user to display, the children is displayed instead.
*/
interface PowerLevelSelectorProps {
/**
* The power levels of the users
* The key is the user id and the value is the power level
*/
userLevels: Record<string, number>;
/**
* Whether the user can change the power levels of other users
*/
canChangeLevels: boolean;
/**
* The current user power level
*/
currentUserLevel: number;
/**
* The callback when the apply button is clicked
* @param value - new power level for the user
* @param userId - the user id
*/
onClick: (value: number, userId: string) => void;
/**
* Filter the users to display
* @param user
*/
filter: (user: string) => boolean;
/**
* The title of the fieldset
*/
title: string;
}
export function PowerLevelSelector({
userLevels,
canChangeLevels,
currentUserLevel,
onClick,
filter,
title,
children,
}: PropsWithChildren<PowerLevelSelectorProps>): JSX.Element | null {
const matrixClient = useMatrixClientContext();
const [currentPowerLevel, setCurrentPowerLevel] = useState<{ value: number; userId: string } | null>(null);
// If the power level has changed, we need to enable the apply button
const powerLevelChanged = Boolean(
currentPowerLevel && currentPowerLevel.value !== userLevels[currentPowerLevel?.userId],
);
// We sort the users by power level, then we filter them
const users = Object.keys(userLevels)
.sort((userA, userB) => sortUser(userA, userB, userLevels))
.filter(filter);
// No user to display, we return the children into fragment to convert it to JSX.Element type
if (!users.length) return <>{children}</>;
return (
<SettingsFieldset legend={title}>
{users.map((userId) => {
// We only want to display users with a valid power level aka an integer
if (!Number.isInteger(userLevels[userId])) return;
const isMe = userId === matrixClient.getUserId();
// If I can change levels, I can change the level of anyone with a lower level than mine
const canChange = canChangeLevels && (userLevels[userId] < currentUserLevel || isMe);
// When the new power level is selected, the fields are rerendered and we need to keep the current value
const userLevel = currentPowerLevel?.userId === userId ? currentPowerLevel?.value : userLevels[userId];
return (
<PowerSelector
value={userLevel}
disabled={!canChange}
label={userId}
key={userId}
onChange={(value) => setCurrentPowerLevel({ value, userId })}
/>
);
})}
<Button
size="sm"
kind="primary"
// mx_Dialog_nonDialogButton is necessary to avoid the Dialog CSS to override the button style
className="mx_Dialog_nonDialogButton mx_PowerLevelSelector_Button"
onClick={() => {
if (currentPowerLevel !== null) {
onClick(currentPowerLevel.value, currentPowerLevel.userId);
setCurrentPowerLevel(null);
}
}}
disabled={!powerLevelChanged}
aria-label={_t("action|apply")}
>
{_t("action|apply")}
</Button>
</SettingsFieldset>
);
}
/**
* Sort the users by power level, then by name
* @param userA
* @param userB
* @param userLevels
*/
function sortUser(userA: string, userB: string, userLevels: PowerLevelSelectorProps["userLevels"]): number {
const powerLevelDiff = userLevels[userA] - userLevels[userB];
return powerLevelDiff !== 0 ? powerLevelDiff : compare(userA.toLocaleLowerCase(), userB.toLocaleLowerCase());
}

View File

@@ -16,7 +16,7 @@ limitations under the License.
*/
import React from "react";
import { ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { IAuthData, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../../languageHandler";
@@ -216,7 +216,7 @@ export default class PhoneNumbers extends React.Component<IProps, IState> {
const address = this.state.verifyMsisdn;
this.state.addTask
?.haveMsisdnToken(token)
.then(([finished] = []) => {
.then(([finished]: [success?: boolean, result?: IAuthData | Error | null] = []) => {
let newPhoneNumber = this.state.newPhoneNumber;
if (finished !== false) {
const msisdns = [...this.props.msisdns, { address, medium: ThreepidMedium.Phone }];

View File

@@ -19,7 +19,7 @@ import { EventType, RoomMember, RoomState, RoomStateEvent, Room, IContent } from
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { throttle, get } from "lodash";
import { compare } from "matrix-js-sdk/src/utils";
import { RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
import { _t, _td, TranslationKey } from "../../../../../languageHandler";
import AccessibleButton from "../../../elements/AccessibleButton";
@@ -35,6 +35,7 @@ import { AddPrivilegedUsers } from "../../AddPrivilegedUsers";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { PowerLevelSelector } from "../../PowerLevelSelector";
interface IEventShowOpts {
isState?: boolean;
@@ -179,7 +180,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const client = this.context;
const room = this.props.room;
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
let plContent = plEvent?.getContent() ?? {};
let plContent = plEvent?.getContent<RoomPowerLevelsEventContent>() ?? {};
// Clone the power levels just in case
plContent = Object.assign({}, plContent);
@@ -193,7 +194,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
} else {
const keyPath = powerLevelKey.split(".");
let parentObj: IContent = {};
let currentObj = plContent;
let currentObj: IContent = plContent;
for (const key of keyPath) {
if (!currentObj[key]) {
currentObj[key] = {};
@@ -223,7 +224,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const client = this.context;
const room = this.props.room;
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
let plContent = plEvent?.getContent() ?? {};
let plContent = plEvent?.getContent<RoomPowerLevelsEventContent>() ?? {};
// Clone the power levels just in case
plContent = Object.assign({}, plContent);
@@ -241,9 +242,6 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
title: _t("room_settings|permissions|error_changing_pl_title"),
description: _t("room_settings|permissions|error_changing_pl_description"),
});
// Rethrow so that the PowerSelector can roll back
throw e;
}
};
@@ -353,65 +351,29 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
let privilegedUsersSection = <div>{_t("room_settings|permissions|no_privileged_users")}</div>;
let mutedUsersSection;
if (Object.keys(userLevels).length) {
const privilegedUsers: JSX.Element[] = [];
const mutedUsers: JSX.Element[] = [];
privilegedUsersSection = (
<PowerLevelSelector
title={_t("room_settings|permissions|privileged_users_section")}
userLevels={userLevels}
canChangeLevels={canChangeLevels}
currentUserLevel={currentUserLevel}
onClick={this.onUserPowerLevelChanged}
filter={(user) => userLevels[user] > defaultUserLevel}
>
<div>{_t("room_settings|permissions|no_privileged_users")}</div>
</PowerLevelSelector>
);
Object.keys(userLevels).forEach((user) => {
if (!Number.isInteger(userLevels[user])) return;
const isMe = user === client.getUserId();
const canChange = canChangeLevels && (userLevels[user] < currentUserLevel || isMe);
if (userLevels[user] > defaultUserLevel) {
// privileged
privilegedUsers.push(
<PowerSelector
value={userLevels[user]}
disabled={!canChange}
label={user}
key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this.onUserPowerLevelChanged}
/>,
);
} else if (userLevels[user] < defaultUserLevel) {
// muted
mutedUsers.push(
<PowerSelector
value={userLevels[user]}
disabled={!canChange}
label={user}
key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this.onUserPowerLevelChanged}
/>,
);
}
});
// comparator for sorting PL users lexicographically on PL descending, MXID ascending. (case-insensitive)
const comparator = (a: JSX.Element, b: JSX.Element): number => {
const aKey = a.key as string;
const bKey = b.key as string;
const plDiff = userLevels[bKey] - userLevels[aKey];
return plDiff !== 0 ? plDiff : compare(aKey.toLocaleLowerCase(), bKey.toLocaleLowerCase());
};
privilegedUsers.sort(comparator);
mutedUsers.sort(comparator);
if (privilegedUsers.length) {
privilegedUsersSection = (
<SettingsFieldset legend={_t("room_settings|permissions|privileged_users_section")}>
{privilegedUsers}
</SettingsFieldset>
);
}
if (mutedUsers.length) {
mutedUsersSection = (
<SettingsFieldset legend={_t("room_settings|permissions|muted_users_section")}>
{mutedUsers}
</SettingsFieldset>
);
}
mutedUsersSection = (
<PowerLevelSelector
title={_t("room_settings|permissions|muted_users_section")}
userLevels={userLevels}
canChangeLevels={canChangeLevels}
currentUserLevel={currentUserLevel}
onClick={this.onUserPowerLevelChanged}
filter={(user) => userLevels[user] < defaultUserLevel}
/>
);
}
const banned = room.getMembersWithMembership(KnownMembership.Ban);

View File

@@ -1892,6 +1892,7 @@
"forget": "Forget Room",
"low_priority": "Low Priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"mentions_only": "Mentions only",
"notifications_default": "Match default setting",
"notifications_mute": "Mute room",

View File

@@ -16,12 +16,14 @@ limitations under the License.
// Inspiration largely taken from Mjolnir itself
import { EventType } from "matrix-js-sdk/src/matrix";
import { ListRule, RECOMMENDATION_BAN, recommendationToStable } from "./ListRule";
import { MatrixClientPeg } from "../MatrixClientPeg";
export const RULE_USER = "m.policy.rule.user";
export const RULE_ROOM = "m.policy.rule.room";
export const RULE_SERVER = "m.policy.rule.server";
export const RULE_USER = EventType.PolicyRuleUser;
export const RULE_ROOM = EventType.PolicyRuleRoom;
export const RULE_SERVER = EventType.PolicyRuleServer;
// m.room.* events are legacy from when MSC2313 changed to m.policy.* last minute.
export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"];
@@ -29,7 +31,9 @@ export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjoln
export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"];
export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES];
export function ruleTypeToStable(rule: string): string | null {
export function ruleTypeToStable(
rule: string,
): EventType.PolicyRuleUser | EventType.PolicyRuleRoom | EventType.PolicyRuleServer | null {
if (USER_RULE_TYPES.includes(rule)) {
return RULE_USER;
}
@@ -72,7 +76,7 @@ export class BanList {
{
entity: entity,
reason: reason,
recommendation: recommendationToStable(RECOMMENDATION_BAN, true),
recommendation: recommendationToStable(RECOMMENDATION_BAN, true)!,
},
"rule:" + entity,
);

View File

@@ -14,14 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// We are using experimental APIs here, so we need to disable the linter
// eslint-disable-next-line no-restricted-imports
import { PolicyRecommendation } from "matrix-js-sdk/src/models/invites-ignorer";
import { MatrixGlob } from "../utils/MatrixGlob";
// Inspiration largely taken from Mjolnir itself
export const RECOMMENDATION_BAN = "m.ban";
export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"];
export const RECOMMENDATION_BAN = PolicyRecommendation.Ban;
export const RECOMMENDATION_BAN_TYPES: PolicyRecommendation[] = [
RECOMMENDATION_BAN,
"org.matrix.mjolnir.ban" as PolicyRecommendation,
];
export function recommendationToStable(recommendation: string, unstable = true): string | null {
export function recommendationToStable(
recommendation: PolicyRecommendation,
unstable = true,
): PolicyRecommendation | null {
if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) {
return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN;
}
@@ -35,7 +45,7 @@ export class ListRule {
private readonly _reason: string;
private readonly _kind: string;
public constructor(entity: string, action: string, reason: string, kind: string) {
public constructor(entity: string, action: PolicyRecommendation, reason: string, kind: string) {
this._glob = new MatrixGlob(entity);
this._entity = entity;
this._action = recommendationToStable(action, false);

View File

@@ -58,6 +58,7 @@ import { UPDATE_EVENT } from "../stores/AsyncStore";
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
import { isVideoRoom } from "../utils/video-rooms";
import { FontWatcher } from "../settings/watchers/FontWatcher";
import { JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
const TIMEOUT_MS = 16000;
@@ -322,18 +323,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
private beforeUnload = (): void => this.setDisconnected();
}
export interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
export type { JitsiCallMemberContent };
/**
* A group call using Jitsi as a backend.
*/
export class JitsiCall extends Call {
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
public static readonly MEMBER_EVENT_TYPE = JitsiCallMemberEventType;
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private resendDevicesTimer: number | null = null;

View File

@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent, StateEvents } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
@@ -24,6 +24,9 @@ import { SettingLevel } from "../SettingLevel";
import { WatchManager } from "../WatchManager";
const DEFAULT_SETTINGS_EVENT_TYPE = "im.vector.web.settings";
const PREVIEW_URLS_EVENT_TYPE = "org.matrix.room.preview_urls";
type RoomSettingsEventType = typeof DEFAULT_SETTINGS_EVENT_TYPE | typeof PREVIEW_URLS_EVENT_TYPE;
/**
* Gets and sets settings at the "room" level.
@@ -88,7 +91,12 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
}
// helper function to send state event then await it being echoed back
private async sendStateEvent(roomId: string, eventType: string, field: string, value: any): Promise<void> {
private async sendStateEvent<K extends RoomSettingsEventType, F extends keyof StateEvents[K]>(
roomId: string,
eventType: K,
field: F,
value: StateEvents[K][F],
): Promise<void> {
const content = this.getSettings(roomId, eventType) || {};
content[field] = value;

View File

@@ -63,6 +63,7 @@ import { ActionPayload } from "../dispatcher/payloads";
import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload";
import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload";
import { ModuleRunner } from "../modules/ModuleRunner";
import { setMarkedUnreadState } from "../utils/notifications";
const NUM_JOIN_RETRY = 5;
@@ -498,6 +499,8 @@ export class RoomViewStore extends EventEmitter {
if (room) {
pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore);
this.doMaybeSetCurrentVoiceBroadcastPlayback(room);
await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false);
}
} else if (payload.room_alias) {
// Try the room alias to room ID navigation cache first to avoid

View File

@@ -24,6 +24,7 @@ import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import * as RoomNotifs from "../../RoomNotifs";
import { NotificationState } from "./NotificationState";
import SettingsStore from "../../settings/SettingsStore";
import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications";
export class RoomNotificationState extends NotificationState implements IDestroyable {
public constructor(
@@ -37,6 +38,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.on(RoomEvent.AccountData, this.handleRoomAccountDataUpdate);
this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
@@ -52,6 +54,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.AccountData, this.handleRoomAccountDataUpdate);
cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
}
@@ -91,6 +94,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy
}
};
private handleRoomAccountDataUpdate = (ev: MatrixEvent): void => {
if ([MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE].includes(ev.getType())) {
this.updateNotificationState();
}
};
private updateNotificationState(): void {
const snapshot = this.snapshot();

View File

@@ -43,6 +43,7 @@ import {
Room,
Direction,
THREAD_RELATION_TYPE,
StateEvents,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
@@ -241,7 +242,19 @@ export class StopGapWidgetDriver extends WidgetDriver {
return allAllowed;
}
public async sendEvent<K extends keyof StateEvents>(
eventType: K,
content: StateEvents[K],
stateKey?: string,
targetRoomId?: string,
): Promise<ISendEventDetails>;
public async sendEvent(
eventType: Exclude<EventType, keyof StateEvents>,
content: IContent,
stateKey: null,
targetRoomId?: string,
): Promise<ISendEventDetails>;
public async sendEvent<K extends keyof StateEvents>(
eventType: string,
content: IContent,
stateKey?: string | null,
@@ -255,7 +268,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
let r: { event_id: string } | null;
if (stateKey !== null) {
// state event
r = await client.sendStateEvent(roomId, eventType, content, stateKey);
r = await client.sendStateEvent(roomId, eventType as K, content as StateEvents[K], stateKey);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content["redacts"]);

View File

@@ -28,55 +28,10 @@ import { ReadyWatchingStore } from "../ReadyWatchingStore";
import { SettingLevel } from "../../settings/SettingLevel";
import { arrayFastClone } from "../../utils/arrays";
import { UPDATE_EVENT } from "../AsyncStore";
import { Container, IStoredLayout, ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE, IWidgetLayouts } from "./types";
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
export enum Container {
// "Top" is the app drawer, and currently the only sensible value.
Top = "top",
// "Right" is the right panel, and the default for widgets. Setting
// this as a container on a widget is essentially like saying "no
// changes needed", though this may change in the future.
Right = "right",
Center = "center",
}
export interface IStoredLayout {
// Where to store the widget. Required.
container: Container;
// The index (order) to position the widgets in. Only applies for
// ordered containers (like the top container). Smaller numbers first,
// and conflicts resolved by comparing widget IDs.
index?: number;
// Percentage (integer) for relative width of the container to consume.
// Clamped to 0-100 and may have minimums imposed upon it. Only applies
// to containers which support inner resizing (currently only the top
// container).
width?: number;
// Percentage (integer) for relative height of the container. Note that
// this only applies to the top container currently, and that container
// will take the highest value among widgets in the container. Clamped
// to 0-100 and may have minimums imposed on it.
height?: number | null;
// TODO: [Deferred] Maximizing (fullscreen) widgets by default.
}
interface IWidgetLayouts {
[widgetId: string]: IStoredLayout;
}
interface ILayoutStateEvent {
// TODO: [Deferred] Forced layout (fixed with no changes)
// The widget layouts.
widgets: IWidgetLayouts;
}
export type { IStoredLayout, ILayoutStateEvent };
export { Container, WIDGET_LAYOUT_EVENT_TYPE };
interface ILayoutSettings extends ILayoutStateEvent {
overrides?: string; // event ID for layout state event, if present

View File

@@ -0,0 +1,64 @@
/*
Copyright 2024 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.
*/
export interface IStoredLayout {
// Where to store the widget. Required.
container: Container;
// The index (order) to position the widgets in. Only applies for
// ordered containers (like the top container). Smaller numbers first,
// and conflicts resolved by comparing widget IDs.
index?: number;
// Percentage (integer) for relative width of the container to consume.
// Clamped to 0-100 and may have minimums imposed upon it. Only applies
// to containers which support inner resizing (currently only the top
// container).
width?: number;
// Percentage (integer) for relative height of the container. Note that
// this only applies to the top container currently, and that container
// will take the highest value among widgets in the container. Clamped
// to 0-100 and may have minimums imposed on it.
height?: number | null;
// TODO: [Deferred] Maximizing (fullscreen) widgets by default.
}
export interface IWidgetLayouts {
[widgetId: string]: IStoredLayout;
}
export interface ILayoutStateEvent {
// TODO: [Deferred] Forced layout (fixed with no changes)
// The widget layouts.
widgets: IWidgetLayouts;
}
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
export enum Container {
// "Top" is the app drawer, and currently the only sensible value.
Top = "top",
// "Right" is the right panel, and the default for widgets. Setting
// this as a container on a widget is essentially like saying "no
// changes needed", though this may change in the future.
Right = "right",
Center = "center",
}

View File

@@ -135,7 +135,7 @@ export async function upgradeRoom(
EventType.SpaceChild,
{
...(currentEv?.getContent() || {}), // copy existing attributes like suggested
via: [cli.getDomain()],
via: [cli.getDomain()!],
},
newRoomId,
);

View File

@@ -27,13 +27,13 @@ export function textToHtmlRainbow(str: string): string {
const [a, b] = generateAB(i * frequency, 1);
const [red, green, blue] = labToRGB(75, a, b);
return (
'<font color="#' +
'<span data-mx-color="#' +
red.toString(16).padStart(2, "0") +
green.toString(16).padStart(2, "0") +
blue.toString(16).padStart(2, "0") +
'">' +
c +
"</font>"
"</span>"
);
})
.join("");

View File

@@ -18,6 +18,6 @@ export * from "./findMapStyleUrl";
export * from "./isSelfLocation";
export * from "./locationEventGeoUri";
export * from "./LocationShareErrors";
export * from "./map";
export * from "./links";
export * from "./parseGeoUri";
export * from "./positionFailureMessage";

View File

@@ -0,0 +1,47 @@
/*
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 { MatrixEvent, M_LOCATION } from "matrix-js-sdk/src/matrix";
import { parseGeoUri } from "./parseGeoUri";
export const makeMapSiteLink = (coords: GeolocationCoordinates): string => {
return (
"https://www.openstreetmap.org/" +
`?mlat=${coords.latitude}` +
`&mlon=${coords.longitude}` +
`#map=16/${coords.latitude}/${coords.longitude}`
);
};
export const createMapSiteLinkFromEvent = (event: MatrixEvent): string | null => {
const content = event.getContent();
const mLocation = content[M_LOCATION.name];
if (mLocation !== undefined) {
const uri = mLocation["uri"];
if (uri !== undefined) {
const geoCoords = parseGeoUri(uri);
return geoCoords ? makeMapSiteLink(geoCoords) : null;
}
} else {
const geoUri = content["geo_uri"];
if (geoUri) {
const geoCoords = parseGeoUri(geoUri);
return geoCoords ? makeMapSiteLink(geoCoords) : null;
}
}
return null;
};

View File

@@ -15,11 +15,10 @@ limitations under the License.
*/
import * as maplibregl from "maplibre-gl";
import { MatrixClient, MatrixEvent, M_LOCATION } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../languageHandler";
import { parseGeoUri } from "./parseGeoUri";
import { findMapStyleUrl } from "./findMapStyleUrl";
import { LocationShareError } from "./LocationShareErrors";
@@ -75,31 +74,3 @@ export const createMarker = (coords: GeolocationCoordinates, element: HTMLElemen
}).setLngLat({ lon: coords.longitude, lat: coords.latitude });
return marker;
};
export const makeMapSiteLink = (coords: GeolocationCoordinates): string => {
return (
"https://www.openstreetmap.org/" +
`?mlat=${coords.latitude}` +
`&mlon=${coords.longitude}` +
`#map=16/${coords.latitude}/${coords.longitude}`
);
};
export const createMapSiteLinkFromEvent = (event: MatrixEvent): string | null => {
const content = event.getContent();
const mLocation = content[M_LOCATION.name];
if (mLocation !== undefined) {
const uri = mLocation["uri"];
if (uri !== undefined) {
const geoCoords = parseGeoUri(uri);
return geoCoords ? makeMapSiteLink(geoCoords) : null;
}
} else {
const geoUri = content["geo_uri"];
if (geoUri) {
const geoCoords = parseGeoUri(geoUri);
return geoCoords ? makeMapSiteLink(geoCoords) : null;
}
}
return null;
};

View File

@@ -15,8 +15,8 @@ limitations under the License.
*/
import { useEffect, useState } from "react";
import { Map as MapLibreMap } from "maplibre-gl";
import type { Map as MapLibreMap } from "maplibre-gl";
import { createMap } from "./map";
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";

View File

@@ -21,6 +21,7 @@ import {
Room,
LocalNotificationSettings,
ReceiptType,
IMarkedUnreadEvent,
} from "matrix-js-sdk/src/matrix";
import { IndicatorIcon } from "@vector-im/compound-web";
@@ -28,6 +29,19 @@ import SettingsStore from "../settings/SettingsStore";
import { NotificationLevel } from "../stores/notifications/NotificationLevel";
import { doesRoomHaveUnreadMessages } from "../Unread";
// MSC2867 is not yet spec at time of writing. We read from both stable
// and unstable prefixes and accept the risk that the format may change,
// since the stable prefix is not actually defined yet.
/**
* Unstable identifier for the marked_unread event, per MSC2867
*/
export const MARKED_UNREAD_TYPE_UNSTABLE = "com.famedly.marked_unread";
/**
* Stable identifier for the marked_unread event
*/
export const MARKED_UNREAD_TYPE_STABLE = "m.marked_unread";
export const deviceNotificationSettingsKeys = [
"notificationsEnabled",
"notificationBodyEnabled",
@@ -74,6 +88,8 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean {
export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> {
const lastEvent = room.getLastLiveEvent();
await setMarkedUnreadState(room, client, false);
try {
if (lastEvent) {
const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId)
@@ -117,6 +133,39 @@ export function clearAllNotifications(client: MatrixClient): Promise<Array<{} |
return Promise.all(receiptPromises);
}
/**
* Gives the marked_unread state of the given room
* @param room The room to check
* @returns - The marked_unread state of the room, or undefined if no explicit state is set.
*/
export function getMarkedUnreadState(room: Room): boolean | undefined {
const currentStateStable = room.getAccountData(MARKED_UNREAD_TYPE_STABLE)?.getContent<IMarkedUnreadEvent>()?.unread;
const currentStateUnstable = room
.getAccountData(MARKED_UNREAD_TYPE_UNSTABLE)
?.getContent<IMarkedUnreadEvent>()?.unread;
return currentStateStable ?? currentStateUnstable;
}
/**
* Sets the marked_unread state of the given room. This sets some room account data that indicates to
* clients that the user considers this room to be 'unread', but without any actual notifications.
*
* @param room The room to set
* @param client MatrixClient object to use
* @param unread The new marked_unread state of the room
*/
export async function setMarkedUnreadState(room: Room, client: MatrixClient, unread: boolean): Promise<void> {
// if there's no event, treat this as false as we don't need to send the flag to clear it if the event isn't there
const currentState = getMarkedUnreadState(room);
if (Boolean(currentState) !== unread) {
// Assuming MSC2867 passes FCP with no changes, we should update to start writing
// the flag to the stable prefix (or both) and then ultimately use only the
// stable prefix.
await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_UNSTABLE, { unread });
}
}
/**
* A helper to transform a notification color to the what the Compound Icon Button
* expects