diff --git a/res/css/views/rooms/_NotificationBadge.pcss b/res/css/views/rooms/_NotificationBadge.pcss index 6facab61f7..85895b097e 100644 --- a/res/css/views/rooms/_NotificationBadge.pcss +++ b/res/css/views/rooms/_NotificationBadge.pcss @@ -48,6 +48,12 @@ limitations under the License. border-radius: 6px; } + &.mx_NotificationBadge_knocked { + mask-image: url("$(res)/img/element-icons/ask-to-join.svg"); + width: 12px; + height: 16px; + } + &.mx_NotificationBadge_2char { width: $font-16px; height: $font-16px; diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index be7f8f7562..bf68e4035e 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -144,7 +144,7 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/context-menu.svg"); } - &:not(.mx_RoomTile_minimized) { + &:not(.mx_RoomTile_minimized, .mx_RoomTile_sticky) { &:hover, &:focus-within, &.mx_RoomTile_hasMenuOpen { diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 8eeb0469e5..988a3a5c21 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -27,7 +27,7 @@ import type { IPushRule, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { NotificationColor } from "./stores/notifications/NotificationColor"; import { getUnsentMessages } from "./components/structures/RoomStatusBar"; import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; -import { EffectiveMembership, getEffectiveMembership } from "./utils/membership"; +import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership"; import SettingsStore from "./settings/SettingsStore"; export enum RoomNotifState { @@ -240,6 +240,10 @@ export function determineUnreadState( return { symbol: "!", count: 1, color: NotificationColor.Red }; } + if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) { + return { symbol: "!", count: 1, color: NotificationColor.Red }; + } + if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { return { symbol: null, count: 0, color: NotificationColor.None }; } diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index c6fe4e2ba1..7c094366f2 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -114,7 +114,7 @@ export default class NotificationBadge extends React.PureComponent void; onMouseLeave?: (ev: MouseEvent) => void; children?: ReactNode; @@ -45,12 +46,13 @@ export function StatelessNotificationBadge({ symbol, count, color, + knocked, ...props }: XOR): JSX.Element { const hideBold = useSettingValue("feature_hidebold"); // Don't show a badge if we don't need to - if (color === NotificationColor.None || (hideBold && color == NotificationColor.Bold)) { + if ((color === NotificationColor.None || (hideBold && color == NotificationColor.Bold)) && !knocked) { return <>; } @@ -64,9 +66,10 @@ export function StatelessNotificationBadge({ const classes = classNames({ mx_NotificationBadge: true, - mx_NotificationBadge_visible: isEmptyBadge ? true : hasUnreadCount, + mx_NotificationBadge_visible: isEmptyBadge || knocked ? true : hasUnreadCount, mx_NotificationBadge_highlighted: color >= NotificationColor.Red, - mx_NotificationBadge_dot: isEmptyBadge, + mx_NotificationBadge_dot: isEmptyBadge && !knocked, + mx_NotificationBadge_knocked: knocked, mx_NotificationBadge_2char: symbol && symbol.length > 0 && symbol.length < 3, mx_NotificationBadge_3char: symbol && symbol.length > 2, }); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 31e4ba269a..465a3d0a93 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -51,6 +51,8 @@ import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast"; import { RoomTileSubtitle } from "./RoomTileSubtitle"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; +import { isKnockDenied } from "../../../utils/membership"; +import SettingsStore from "../../../settings/SettingsStore"; interface Props { room: Room; @@ -120,7 +122,12 @@ export class RoomTile extends React.PureComponent { }; private get showContextMenu(): boolean { - return this.props.tag !== DefaultTagID.Invite && shouldShowComponent(UIComponent.RoomOptionsMenu); + return ( + this.props.tag !== DefaultTagID.Invite && + this.props.room.getMyMembership() !== "knock" && + !isKnockDenied(this.props.room) && + shouldShowComponent(UIComponent.RoomOptionsMenu) + ); } private get showMessagePreview(): boolean { @@ -378,6 +385,9 @@ export class RoomTile extends React.PureComponent { public render(): React.ReactElement { const classes = classNames({ mx_RoomTile: true, + mx_RoomTile_sticky: + SettingsStore.getValue("feature_ask_to_join") && + (this.props.room.getMyMembership() === "knock" || isKnockDenied(this.props.room)), mx_RoomTile_selected: this.state.selected, mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition), mx_RoomTile_minimized: this.props.isMinimized, diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index 63537eb610..6de2d6b853 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -25,6 +25,7 @@ export interface INotificationStateSnapshotParams { count: number; color: NotificationColor; muted: boolean; + knocked: boolean; } export enum NotificationStateEvents { @@ -44,6 +45,7 @@ export abstract class NotificationState protected _count = 0; protected _color: NotificationColor = NotificationColor.None; protected _muted = false; + protected _knocked = false; private watcherReferences: string[] = []; @@ -72,6 +74,10 @@ export abstract class NotificationState return this._muted; } + public get knocked(): boolean { + return this._knocked; + } + public get isIdle(): boolean { return this.color <= NotificationColor.None; } @@ -117,17 +123,31 @@ export class NotificationStateSnapshot { private readonly count: number; private readonly color: NotificationColor; private readonly muted: boolean; + private readonly knocked: boolean; public constructor(state: INotificationStateSnapshotParams) { this.symbol = state.symbol; this.count = state.count; this.color = state.color; this.muted = state.muted; + this.knocked = state.knocked; } public isDifferentFrom(other: INotificationStateSnapshotParams): boolean { - const before = { count: this.count, symbol: this.symbol, color: this.color, muted: this.muted }; - const after = { count: other.count, symbol: other.symbol, color: other.color, muted: other.muted }; + const before = { + count: this.count, + symbol: this.symbol, + color: this.color, + muted: this.muted, + knocked: this.knocked, + }; + const after = { + count: other.count, + symbol: other.symbol, + color: other.color, + muted: other.muted, + knocked: other.knocked, + }; return JSON.stringify(before) !== JSON.stringify(after); } } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 052aacc4ae..44b4931f90 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -22,6 +22,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import * as RoomNotifs from "../../RoomNotifs"; import { NotificationState } from "./NotificationState"; +import SettingsStore from "../../settings/SettingsStore"; export class RoomNotificationState extends NotificationState implements IDestroyable { public constructor(public readonly room: Room) { @@ -92,10 +93,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room); const muted = RoomNotifs.getRoomNotifsState(this.room.client, this.room.roomId) === RoomNotifs.RoomNotifState.Mute; + const knocked = SettingsStore.getValue("feature_ask_to_join") && this.room.getMyMembership() === "knock"; this._color = color; this._symbol = symbol; this._count = count; this._muted = muted; + this._knocked = knocked; // finally, publish an update if needed this.emitIfUpdated(snapshot); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index e35726b338..b6e65fada7 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -25,7 +25,7 @@ import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; -import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; +import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership"; import RoomListLayoutStore from "./RoomListLayoutStore"; import { MarkedExecution } from "../../utils/MarkedExecution"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -308,7 +308,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements public async onDispatchMyMembership(membershipPayload: any): Promise { // TODO: Type out the dispatcher types so membershipPayload is not any const oldMembership = getEffectiveMembership(membershipPayload.oldMembership); - const newMembership = getEffectiveMembership(membershipPayload.membership); + const newMembership = getEffectiveMembershipTag(membershipPayload.room, membershipPayload.membership); if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { // If we're joining an upgraded room, we'll want to make sure we don't proliferate // the dead room in the list. diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 3ae5fcfea3..cc81d36249 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -30,7 +30,12 @@ import { ListAlgorithm, SortAlgorithm, } from "./models"; -import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } from "../../../utils/membership"; +import { + EffectiveMembership, + getEffectiveMembership, + getEffectiveMembershipTag, + splitRoomsByMembership, +} from "../../../utils/membership"; import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import { VisibilityProvider } from "../filters/VisibilityProvider"; @@ -543,8 +548,9 @@ export class Algorithm extends EventEmitter { public getTagsForRoom(room: Room): TagID[] { const tags: TagID[] = []; - const membership = getEffectiveMembership(room.getMyMembership()); - if (!membership) return []; // peeked room has no tags + if (!getEffectiveMembership(room.getMyMembership())) return []; // peeked room has no tags + + const membership = getEffectiveMembershipTag(room); if (membership === EffectiveMembership.Invite) { tags.push(DefaultTagID.Invite); diff --git a/src/utils/membership.ts b/src/utils/membership.ts index 2977062029..df012e442b 100644 --- a/src/utils/membership.ts +++ b/src/utils/membership.ts @@ -16,6 +16,9 @@ limitations under the License. import { Room, RoomMember, RoomState, RoomStateEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import SettingsStore from "../settings/SettingsStore"; + /** * Approximation of a membership status for a given room. */ @@ -55,7 +58,7 @@ export function splitRoomsByMembership(rooms: Room[]): MembershipSplit { const membership = room.getMyMembership(); // Filter out falsey relationship as this will be peeked rooms if (!!membership) { - split[getEffectiveMembership(membership)].push(room); + split[getEffectiveMembershipTag(room)].push(room); } } @@ -65,8 +68,7 @@ export function splitRoomsByMembership(rooms: Room[]): MembershipSplit { export function getEffectiveMembership(membership: string): EffectiveMembership { if (membership === "invite") { return EffectiveMembership.Invite; - } else if (membership === "join") { - // TODO: Include knocks? Update docs as needed in the enum. https://github.com/vector-im/element-web/issues/14237 + } else if (membership === "join" || (SettingsStore.getValue("feature_ask_to_join") && membership === "knock")) { return EffectiveMembership.Join; } else { // Probably a leave, kick, or ban @@ -74,6 +76,20 @@ export function getEffectiveMembership(membership: string): EffectiveMembership } } +export function isKnockDenied(room: Room): boolean | undefined { + const memberId = MatrixClientPeg.get()?.getSafeUserId(); + const member = memberId ? room.getMember(memberId) : null; + const previousMembership = member?.events.member?.getPrevContent().membership; + + return member?.isKicked() && previousMembership === "knock"; +} + +export function getEffectiveMembershipTag(room: Room, membership?: string): EffectiveMembership { + return isKnockDenied(room) + ? EffectiveMembership.Join + : getEffectiveMembership(membership ?? room.getMyMembership()); +} + export function isJoinedOrNearlyJoined(membership: string): boolean { const effective = getEffectiveMembership(membership); return effective === EffectiveMembership.Join || effective === EffectiveMembership.Invite; diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts index 43ea09923c..3544e862c2 100644 --- a/test/RoomNotifs-test.ts +++ b/test/RoomNotifs-test.ts @@ -27,7 +27,7 @@ import { } from "matrix-js-sdk/src/matrix"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { mkEvent, mkRoom, muteRoom, stubClient, upsertRoomStateEvents } from "./test-utils"; +import { mkEvent, mkRoom, mkRoomMember, muteRoom, stubClient, upsertRoomStateEvents } from "./test-utils"; import { getRoomNotifsState, RoomNotifState, @@ -36,6 +36,7 @@ import { } from "../src/RoomNotifs"; import { NotificationColor } from "../src/stores/notifications/NotificationColor"; import SettingsStore from "../src/settings/SettingsStore"; +import { MatrixClientPeg } from "../src/MatrixClientPeg"; describe("RoomNotifs test", () => { let client: jest.Mocked; @@ -285,6 +286,21 @@ describe("RoomNotifs test", () => { expect(count).toBeGreaterThan(0); }); + it("indicates the user knock has been denied", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { + return name === "feature_ask_to_join"; + }); + const roomMember = mkRoomMember(room.roomId, MatrixClientPeg.get()!.getSafeUserId(), "leave", true, { + membership: "knock", + }); + jest.spyOn(room, "getMember").mockReturnValue(roomMember); + const { color, symbol, count } = determineUnreadState(room); + + expect(symbol).toBe("!"); + expect(color).toBe(NotificationColor.Red); + expect(count).toBeGreaterThan(0); + }); + it("shows nothing for muted channels", async () => { room.setUnreadNotificationCount(NotificationCountType.Highlight, 99); room.setUnreadNotificationCount(NotificationCountType.Total, 99); diff --git a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx index 5eecff326f..c6d2b843e6 100644 --- a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx @@ -27,4 +27,12 @@ describe("StatelessNotificationBadge", () => { ); expect(container.querySelector(".mx_NotificationBadge_highlighted")).not.toBe(null); }); + + it("has knock style", () => { + const { container } = render( + , + ); + expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + expect(container.querySelector(".mx_NotificationBadge_knocked")).toBeInTheDocument(); + }); }); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index d15618e36a..4e8e631195 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -54,6 +54,8 @@ import { SDKContext } from "../../../../src/contexts/SDKContext"; import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../src/settings/UIFeature"; import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -160,8 +162,9 @@ describe("RoomTile", () => { describe("when message previews are not enabled", () => { it("should render the room", () => { mocked(shouldShowComponent).mockReturnValue(true); - const renderResult = renderRoomTile(); - expect(renderResult.container).toMatchSnapshot(); + const { container } = renderRoomTile(); + expect(container).toMatchSnapshot(); + expect(container.querySelector(".mx_RoomTile_sticky")).not.toBeInTheDocument(); }); it("does not render the room options context menu when UIComponent customisations disable room options", () => { @@ -178,6 +181,31 @@ describe("RoomTile", () => { expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument(); }); + it("does not render the room options context menu when knocked to the room", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { + return name === "feature_ask_to_join"; + }); + mocked(shouldShowComponent).mockReturnValue(true); + jest.spyOn(room, "getMyMembership").mockReturnValue("knock"); + const { container } = renderRoomTile(); + expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument(); + }); + + it("does not render the room options context menu when knock has been denied", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { + return name === "feature_ask_to_join"; + }); + mocked(shouldShowComponent).mockReturnValue(true); + const roomMember = mkRoomMember(room.roomId, MatrixClientPeg.get()!.getSafeUserId(), "leave", true, { + membership: "knock", + }); + jest.spyOn(room, "getMember").mockReturnValue(roomMember); + const { container } = renderRoomTile(); + expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument(); + }); + describe("when a call starts", () => { let call: MockedCall; let widget: Widget; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 35a926f7d3..47f3713364 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -464,14 +464,26 @@ export function mkMembership( return e; } -export function mkRoomMember(roomId: string, userId: string, membership = "join"): RoomMember { +export function mkRoomMember( + roomId: string, + userId: string, + membership = "join", + isKicked = false, + prevMemberContent: Partial = {}, +): RoomMember { return { userId, membership, name: userId, rawDisplayName: userId, roomId, - events: {}, + events: { + member: { + getSender: () => undefined, + getPrevContent: () => prevMemberContent, + }, + }, + isKicked: () => isKicked, getAvatarUrl: () => {}, getMxcAvatarUrl: () => {}, getDMInviter: () => {}, @@ -597,6 +609,8 @@ export function mkStubRoom( roomId: roomId, getAvatarUrl: () => "mxc://avatar.url/image.png", getMxcAvatarUrl: () => "mxc://avatar.url/image.png", + events: {}, + isKicked: () => false, }), getMembers: jest.fn().mockReturnValue([]), getMembersWithMembership: jest.fn().mockReturnValue([]), diff --git a/test/utils/membership-test.ts b/test/utils/membership-test.ts index 67bf3d98f7..3561625573 100644 --- a/test/utils/membership-test.ts +++ b/test/utils/membership-test.ts @@ -17,8 +17,36 @@ limitations under the License. import { MatrixClient, MatrixEvent, Room, RoomMember, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; -import { waitForMember } from "../../src/utils/membership"; -import { createTestClient } from "../test-utils"; +import { isKnockDenied, waitForMember } from "../../src/utils/membership"; +import { createTestClient, mkRoomMember, stubClient } from "../test-utils"; + +describe("isKnockDenied", () => { + const userId = "alice"; + let client: jest.Mocked; + let room: Room; + + beforeEach(() => { + client = stubClient() as jest.Mocked; + room = new Room("!room-id:example.com", client, "@user:example.com"); + }); + + it("checks that the user knock has been denied", () => { + const roomMember = mkRoomMember(room.roomId, userId, "leave", true, { membership: "knock" }); + jest.spyOn(room, "getMember").mockReturnValue(roomMember); + expect(isKnockDenied(room)).toBe(true); + }); + + it.each([ + { membership: "leave", isKicked: false, prevMembership: "invite" }, + { membership: "leave", isKicked: true, prevMembership: "invite" }, + { membership: "leave", isKicked: false, prevMembership: "join" }, + { membership: "leave", isKicked: true, prevMembership: "join" }, + ])("checks that the user knock has been not denied", ({ membership, isKicked, prevMembership }) => { + const roomMember = mkRoomMember(room.roomId, userId, membership, isKicked, { membership: prevMembership }); + jest.spyOn(room, "getMember").mockReturnValue(roomMember); + expect(isKnockDenied(room)).toBe(false); + }); +}); /* Shorter timeout, we've got tests to run */ const timeout = 30;