diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 8329de73915..a97d51fc90d 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { MouseEvent } from "react"; import classNames from "classnames"; import { formatCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; @@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Tooltip from "../elements/Tooltip"; +import { _t } from "../../../languageHandler"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; interface IProps { notification: NotificationState; @@ -39,6 +42,7 @@ interface IProps { } interface IClickableProps extends IProps, React.InputHTMLAttributes { + showUnsentTooltip?: boolean; /** * If specified will return an AccessibleButton instead of a div. */ @@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes { interface IState { showCounts: boolean; // whether or not to show counts. Independent of props.forceCount + showTooltip: boolean; } @replaceableComponent("views.rooms.NotificationBadge") @@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent { + e.stopPropagation(); + this.setState({ + showTooltip: true, + }); + }; + + private onMouseLeave = () => { + this.setState({ + showTooltip: false, + }); + }; + public render(): React.ReactElement { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { notification, forceCount, roomId, onClick, ...props } = this.props; + const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props; // Don't show a badge if we don't need to if (notification.isIdle) return null; @@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent; + } + return ( - + { symbol } + { tooltip } ); } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index cf820407939..3c9f0ea65e3 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component { onClick={this.onBadgeClick} tabIndex={tabIndex} aria-label={ariaLabel} + showUnsentTooltip={true} /> ); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 4d6de10e1f4..8ed2ca85e9f 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -17,7 +17,6 @@ limitations under the License. import React, { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; @@ -51,8 +50,6 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { getUnsentMessages } from "../../structures/RoomStatusBar"; -import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; interface IProps { room: Room; @@ -68,7 +65,6 @@ interface IState { notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; messagePreview?: string; - hasUnsentEvents: boolean; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent { selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, - hasUnsentEvents: this.countUnsentEvents() > 0, // generatePreview() will return nothing if the user has previews disabled messagePreview: "", @@ -106,10 +101,6 @@ export default class RoomTile extends React.PureComponent { this.roomProps = EchoChamber.forRoom(this.props.room); } - private countUnsentEvents(): number { - return getUnsentMessages(this.props.room).length; - } - private onRoomNameUpdate = (room) => { this.forceUpdate(); }; @@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent { this.forceUpdate(); // notification state changed - update }; - private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => { - if (room?.roomId !== this.props.room.roomId) return; - this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 }); - }; - private onRoomPropertyUpdate = (property: CachedRoomKey) => { if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); // else ignore - not important for this tile @@ -183,7 +169,6 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); - MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); } public componentWillUnmount() { @@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); - MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); } private onAction = (payload: ActionPayload) => { @@ -587,30 +571,17 @@ export default class RoomTile extends React.PureComponent { />; let badge: React.ReactNode; - if (!this.props.isMinimized) { + if (!this.props.isMinimized && this.notificationState) { // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below - if (this.state.hasUnsentEvents) { - // hardcode the badge to a danger state when there's unsent messages - badge = ( - - ); - } else if (this.notificationState) { - badge = ( - - ); - } + badge = ( + + ); } let messagePreview = null; diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index b467dc7680b..d1c5bc8448b 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -425,7 +425,7 @@ export default class SecurityRoomSettingsTab extends React.Component state.getJoinRule()); const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho( () => space.currentState.getStateEvents(EventType.RoomGuestAccess, "") ?.getContent()?.guest_access === GuestAccess.CanJoin, @@ -64,7 +66,7 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space, closeSettingsFn const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); let advancedSection; - if (visibility === SpaceVisibility.Unlisted) { + if (joinRule === JoinRule.Public) { if (showAdvancedSection) { advancedSection = <> diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 35c02752406..df6c4c81498 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -93,6 +93,7 @@ export const SpaceButton: React.FC = ({ notification={notificationState} aria-label={ariaLabel} tabIndex={tabIndex} + showUnsentTooltip={true} /> ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 562d3a4232a..5a5d8df1bb3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1593,6 +1593,7 @@ "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.", "Enable encryption in settings.": "Enable encryption in settings.", "End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled", + "Message didn't send. Click for info.": "Message didn't send. Click for info.", "Unpin": "Unpin", "View message": "View message", "%(duration)ss": "%(duration)ss", diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts index 6c67dbdd080..97ba2bd80b7 100644 --- a/src/stores/notifications/ListNotificationState.ts +++ b/src/stores/notifications/ListNotificationState.ts @@ -32,7 +32,7 @@ export class ListNotificationState extends NotificationState { } public get symbol(): string { - return null; // This notification state doesn't support symbols + return this._color === NotificationColor.Unsent ? "!" : null; } public setRooms(rooms: Room[]) { diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts index b12f2b7c003..fadd5ac67e5 100644 --- a/src/stores/notifications/NotificationColor.ts +++ b/src/stores/notifications/NotificationColor.ts @@ -21,4 +21,5 @@ export enum NotificationColor { Bold, // no badge, show as unread Grey, // unread notified messages Red, // unread pings + Unsent, // some messages failed to send } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 3fadbe7d7ac..d0479200bda 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -24,6 +24,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import * as RoomNotifs from '../../RoomNotifs'; import * as Unread from '../../Unread'; import { NotificationState } from "./NotificationState"; +import { getUnsentMessages } from "../../components/structures/RoomStatusBar"; export class RoomNotificationState extends NotificationState implements IDestroyable { constructor(public readonly room: Room) { @@ -32,6 +33,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.room.on("Room.timeline", this.handleRoomEventUpdate); this.room.on("Room.redaction", this.handleRoomEventUpdate); this.room.on("Room.myMembership", this.handleMembershipUpdate); + this.room.on("Room.localEchoUpdated", this.handleLocalEchoUpdated); MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate); MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate); this.updateNotificationState(); @@ -47,12 +49,17 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); this.room.removeListener("Room.myMembership", this.handleMembershipUpdate); + this.room.removeListener("Room.localEchoUpdated", this.handleLocalEchoUpdated); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate); MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate); } } + private handleLocalEchoUpdated = () => { + this.updateNotificationState(); + }; + private handleReadReceipt = (event: MatrixEvent, room: Room) => { if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore if (room.roomId !== this.room.roomId) return; // not for us - ignore @@ -79,7 +86,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy private updateNotificationState() { const snapshot = this.snapshot(); - if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { + if (getUnsentMessages(this.room).length > 0) { + // When there are unsent messages we show a red `!` + this._color = NotificationColor.Unsent; + this._symbol = "!"; + this._count = 1; // not used, technically + } else if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { // When muted we suppress all notification states, even if we have context on them. this._color = NotificationColor.None; this._symbol = null; diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index f8eb07251b0..137b2ca0f2f 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -31,7 +31,7 @@ export class SpaceNotificationState extends NotificationState { } public get symbol(): string { - return null; // This notification state doesn't support symbols + return this._color === NotificationColor.Unsent ? "!" : null; } public setRooms(rooms: Room[]) { @@ -54,7 +54,7 @@ export class SpaceNotificationState extends NotificationState { } public getFirstRoomWithNotifications() { - return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId; + return Object.values(this.states).find(state => state.color >= this.color)?.room.roomId; } public destroy() { @@ -83,4 +83,3 @@ export class SpaceNotificationState extends NotificationState { this.emitIfUpdated(snapshot); } } -