From 2b5cacc29b2cd0b09cedaa86cefdca385da036fb Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 6 Mar 2023 17:19:35 +0000 Subject: [PATCH 1/7] Tests for RoomNotificationStateStore emitting events --- .../stores/RoomNotificationStateStore-test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/stores/RoomNotificationStateStore-test.ts diff --git a/test/stores/RoomNotificationStateStore-test.ts b/test/stores/RoomNotificationStateStore-test.ts new file mode 100644 index 00000000000..78dc4b12b8b --- /dev/null +++ b/test/stores/RoomNotificationStateStore-test.ts @@ -0,0 +1,74 @@ +/* +Copyright 2023 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 { mocked } from "jest-mock"; +import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { createTestClient, setupAsyncStoreWithClient } from "../test-utils"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "../../src/stores/notifications/RoomNotificationStateStore"; + +describe("RoomNotificationStateStore", function () { + let store: RoomNotificationStateStore; + const client: MatrixClient = createTestClient(); + + beforeEach(() => { + jest.resetAllMocks(); + store = RoomNotificationStateStore.instance; + setupAsyncStoreWithClient(store, client); + }); + + it("Emits no event when a room has no unreads", async () => { + // Given a room with 0 unread messages + const room = fakeRoom(0); + store.emit = jest.fn(); + + // When we sync and the room is visible + mocked(client.getVisibleRooms).mockReturnValue([room]); + client.emit(ClientEvent.Sync, null, null); + + // Then we emit an event from the store + expect(client.getVisibleRooms).toHaveBeenCalledWith(); + expect(store.emit).not.toHaveBeenCalled(); + }); + + it("Emits an event when a room has unreads", async () => { + // Given a room with 2 unread messages + const room = fakeRoom(2); + store.emit = jest.fn(); + + // When we sync and the room is visible + mocked(client.getVisibleRooms).mockReturnValue([room]); + client.emit(ClientEvent.Sync, null, null); + + // Then we emit an event from the store + expect(client.getVisibleRooms).toHaveBeenCalledWith(); + expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null, null, undefined); + }); + + let roomIdx = 0; + + function fakeRoom(numUnreads: number): Room { + roomIdx++; + const ret = new Room(`room${roomIdx}`, client, "@user:example.com"); + ret.getPendingEvents = jest.fn().mockReturnValue([]); + ret.isSpaceRoom = jest.fn().mockReturnValue(false); + ret.getUnreadNotificationCount = jest.fn().mockReturnValue(numUnreads); + return ret; + } +}); From a972d63a2b3aa5460925e14ee335a56caa79d7de Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 6 Mar 2023 17:29:31 +0000 Subject: [PATCH 2/7] Support dynamic room predecessors in RoomNotificationStateStore --- .../RoomNotificationStateStore.ts | 4 +- .../stores/RoomNotificationStateStore-test.ts | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index b7e7a3863ef..029aa7d857b 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -27,6 +27,7 @@ import { RoomNotificationState } from "./RoomNotificationState"; import { SummarizedNotificationState } from "./SummarizedNotificationState"; import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; import { PosthogAnalytics } from "../../PosthogAnalytics"; +import SettingsStore from "../../settings/SettingsStore"; interface IState {} @@ -96,8 +97,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { private onSync = (state: SyncState, prevState: SyncState | null, data?: ISyncStateData): void => { // Only count visible rooms to not torment the user with notification counts in rooms they can't see. // This will include highlights from the previous version of the room internally + const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); const globalState = new SummarizedNotificationState(); - const visibleRooms = this.matrixClient.getVisibleRooms(); + const visibleRooms = this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor); let numFavourites = 0; for (const room of visibleRooms) { diff --git a/test/stores/RoomNotificationStateStore-test.ts b/test/stores/RoomNotificationStateStore-test.ts index 78dc4b12b8b..bc0846a75e1 100644 --- a/test/stores/RoomNotificationStateStore-test.ts +++ b/test/stores/RoomNotificationStateStore-test.ts @@ -22,6 +22,7 @@ import { RoomNotificationStateStore, UPDATE_STATUS_INDICATOR, } from "../../src/stores/notifications/RoomNotificationStateStore"; +import SettingsStore from "../../src/settings/SettingsStore"; describe("RoomNotificationStateStore", function () { let store: RoomNotificationStateStore; @@ -43,7 +44,6 @@ describe("RoomNotificationStateStore", function () { client.emit(ClientEvent.Sync, null, null); // Then we emit an event from the store - expect(client.getVisibleRooms).toHaveBeenCalledWith(); expect(store.emit).not.toHaveBeenCalled(); }); @@ -57,10 +57,47 @@ describe("RoomNotificationStateStore", function () { client.emit(ClientEvent.Sync, null, null); // Then we emit an event from the store - expect(client.getVisibleRooms).toHaveBeenCalledWith(); expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null, null, undefined); }); + describe("If the feature_dynamic_room_predecessors is not enabled", () => { + beforeEach(() => { + // Turn off feature_dynamic_room_predecessors setting + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + }); + + it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { + // When we sync + mocked(client.getVisibleRooms).mockReturnValue([]); + store.emit = jest.fn(); + client.emit(ClientEvent.Sync, null, null); + + // Then we check visible rooms, using the dynamic predecessor flag + expect(client.getVisibleRooms).toHaveBeenCalledWith(false); + expect(client.getVisibleRooms).not.toHaveBeenCalledWith(true); + }); + }); + + describe("If the feature_dynamic_room_predecessors is enabled", () => { + beforeEach(() => { + // Turn on feature_dynamic_room_predecessors setting + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_dynamic_room_predecessors", + ); + }); + + it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { + // When we sync + mocked(client.getVisibleRooms).mockReturnValue([]); + store.emit = jest.fn(); + client.emit(ClientEvent.Sync, null, null); + + // Then we check visible rooms, using the dynamic predecessor flag + expect(client.getVisibleRooms).toHaveBeenCalledWith(true); + expect(client.getVisibleRooms).not.toHaveBeenCalledWith(false); + }); + }); + let roomIdx = 0; function fakeRoom(numUnreads: number): Room { From 1d3ed497f3293edb8eeebd2474e568c95c609a97 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 7 Mar 2023 14:02:24 +0000 Subject: [PATCH 3/7] Remove unused arguments from emit call. UPDATE_STATUS_INDICATOR is used in: * SpacePanel * MatrixChat * RoomHeaderButtons but these arguments are not used in any of those places. Remove them so when I refactor I don't have to make up values for them. --- src/stores/notifications/RoomNotificationStateStore.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 029aa7d857b..f88525ea140 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; +import { SyncState } from "matrix-js-sdk/src/sync"; import { ClientEvent } from "matrix-js-sdk/src/client"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -94,7 +94,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { return RoomNotificationStateStore.internalInstance; } - private onSync = (state: SyncState, prevState: SyncState | null, data?: ISyncStateData): void => { + private onSync = (state: SyncState, prevState: SyncState | null): void => { // Only count visible rooms to not torment the user with notification counts in rooms they can't see. // This will include highlights from the previous version of the room internally const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); @@ -120,7 +120,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { state !== prevState ) { this._globalState = globalState; - this.emit(UPDATE_STATUS_INDICATOR, globalState, state, prevState, data); + this.emit(UPDATE_STATUS_INDICATOR, globalState, state); } }; From ab580cf9aeb641d40e3f6afd6e978f3421e1b8c1 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 7 Mar 2023 14:02:24 +0000 Subject: [PATCH 4/7] Fix broken test (wrong expected args to emit) UPDATE_STATUS_INDICATOR is used in: * SpacePanel * MatrixChat * RoomHeaderButtons but these arguments are not used in any of those places. Remove them so when I refactor I don't have to make up values for them. --- test/stores/RoomNotificationStateStore-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/stores/RoomNotificationStateStore-test.ts b/test/stores/RoomNotificationStateStore-test.ts index bc0846a75e1..52f9de80ce5 100644 --- a/test/stores/RoomNotificationStateStore-test.ts +++ b/test/stores/RoomNotificationStateStore-test.ts @@ -57,7 +57,7 @@ describe("RoomNotificationStateStore", function () { client.emit(ClientEvent.Sync, null, null); // Then we emit an event from the store - expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null, null, undefined); + expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null); }); describe("If the feature_dynamic_room_predecessors is not enabled", () => { From a41089bfa6ab543e2a492bbe7c088515be87c073 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 8 Mar 2023 10:30:59 +0000 Subject: [PATCH 5/7] Update the RoomNotificationStore whenever the predecessor labs flag changes --- .../RoomNotificationStateStore.ts | 32 ++++++++++++++++--- .../stores/RoomNotificationStateStore-test.ts | 32 +++++++++++++++---- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index f88525ea140..b0cafc0064d 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -20,7 +20,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { DefaultTagID, TagID } from "../room-list/models"; import { FetchRoomFn, ListNotificationState } from "./ListNotificationState"; import { RoomNotificationState } from "./RoomNotificationState"; @@ -44,8 +44,22 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { private listMap = new Map(); private _globalState = new SummarizedNotificationState(); - private constructor() { - super(defaultDispatcher, {}); + private constructor(dispatcher = defaultDispatcher) { + super(dispatcher, {}); + SettingsStore.watchSetting("feature_dynamic_room_predecessors", null, () => { + // We pass SyncState.Syncing here to "simulate" a sync happening. + // The code that receives these events actually doesn't care + // what state we pass, except that it behaves differently if we + // pass SyncState.Error. + this.emitUpdateIfStateChanged(SyncState.Syncing, false); + }); + } + + /** + * @internal Public for test only + */ + public static testInstance(dispatcher: MatrixDispatcher): RoomNotificationStateStore { + return new RoomNotificationStateStore(); } /** @@ -95,6 +109,16 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { } private onSync = (state: SyncState, prevState: SyncState | null): void => { + this.emitUpdateIfStateChanged(state, state !== prevState); + }; + + /** + * If the SummarizedNotificationState of this room has changed, or forceEmit + * is true, emit an UPDATE_STATUS_INDICATOR event. + * + * @internal public for test + */ + public emitUpdateIfStateChanged = (state: SyncState, forceEmit: boolean): void => { // Only count visible rooms to not torment the user with notification counts in rooms they can't see. // This will include highlights from the previous version of the room internally const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); @@ -117,7 +141,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { this.globalState.count !== globalState.count || this.globalState.color !== globalState.color || this.globalState.numUnreadStates !== globalState.numUnreadStates || - state !== prevState + forceEmit ) { this._globalState = globalState; this.emit(UPDATE_STATUS_INDICATOR, globalState, state); diff --git a/test/stores/RoomNotificationStateStore-test.ts b/test/stores/RoomNotificationStateStore-test.ts index 52f9de80ce5..bc4ed87e2a9 100644 --- a/test/stores/RoomNotificationStateStore-test.ts +++ b/test/stores/RoomNotificationStateStore-test.ts @@ -16,6 +16,7 @@ limitations under the License. import { mocked } from "jest-mock"; import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { SyncState } from "matrix-js-sdk/src/sync"; import { createTestClient, setupAsyncStoreWithClient } from "../test-utils"; import { @@ -23,21 +24,25 @@ import { UPDATE_STATUS_INDICATOR, } from "../../src/stores/notifications/RoomNotificationStateStore"; import SettingsStore from "../../src/settings/SettingsStore"; +import { MatrixDispatcher } from "../../src/dispatcher/dispatcher"; describe("RoomNotificationStateStore", function () { let store: RoomNotificationStateStore; - const client: MatrixClient = createTestClient(); + let client: MatrixClient; + let dis: MatrixDispatcher; beforeEach(() => { + client = createTestClient(); + dis = new MatrixDispatcher(); jest.resetAllMocks(); - store = RoomNotificationStateStore.instance; + store = RoomNotificationStateStore.testInstance(dis); + store.emit = jest.fn(); setupAsyncStoreWithClient(store, client); }); it("Emits no event when a room has no unreads", async () => { // Given a room with 0 unread messages const room = fakeRoom(0); - store.emit = jest.fn(); // When we sync and the room is visible mocked(client.getVisibleRooms).mockReturnValue([room]); @@ -50,7 +55,6 @@ describe("RoomNotificationStateStore", function () { it("Emits an event when a room has unreads", async () => { // Given a room with 2 unread messages const room = fakeRoom(2); - store.emit = jest.fn(); // When we sync and the room is visible mocked(client.getVisibleRooms).mockReturnValue([room]); @@ -60,6 +64,24 @@ describe("RoomNotificationStateStore", function () { expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null); }); + it("Emits an event when a feature flag changes notification state", async () => { + // Given we have synced already + let room = fakeRoom(0); + mocked(store.emit).mockReset(); + mocked(client.getVisibleRooms).mockReturnValue([room]); + client.emit(ClientEvent.Sync, null, null); + expect(store.emit).not.toHaveBeenCalled(); + + // When we update the feature flag and it makes us have a notification + room = fakeRoom(2); + mocked(client.getVisibleRooms).mockReturnValue([room]); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + store.emitUpdateIfStateChanged(SyncState.Syncing, false); + + // Then we get notified + expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), "SYNCING"); + }); + describe("If the feature_dynamic_room_predecessors is not enabled", () => { beforeEach(() => { // Turn off feature_dynamic_room_predecessors setting @@ -69,7 +91,6 @@ describe("RoomNotificationStateStore", function () { it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { // When we sync mocked(client.getVisibleRooms).mockReturnValue([]); - store.emit = jest.fn(); client.emit(ClientEvent.Sync, null, null); // Then we check visible rooms, using the dynamic predecessor flag @@ -89,7 +110,6 @@ describe("RoomNotificationStateStore", function () { it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { // When we sync mocked(client.getVisibleRooms).mockReturnValue([]); - store.emit = jest.fn(); client.emit(ClientEvent.Sync, null, null); // Then we check visible rooms, using the dynamic predecessor flag From e1c9b750c7f4e0dc5c08b25cfa9613543e93c268 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 8 Mar 2023 11:28:46 +0000 Subject: [PATCH 6/7] Fix type errors --- test/stores/RoomNotificationStateStore-test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/stores/RoomNotificationStateStore-test.ts b/test/stores/RoomNotificationStateStore-test.ts index bc4ed87e2a9..3a63ee74019 100644 --- a/test/stores/RoomNotificationStateStore-test.ts +++ b/test/stores/RoomNotificationStateStore-test.ts @@ -46,7 +46,7 @@ describe("RoomNotificationStateStore", function () { // When we sync and the room is visible mocked(client.getVisibleRooms).mockReturnValue([room]); - client.emit(ClientEvent.Sync, null, null); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // Then we emit an event from the store expect(store.emit).not.toHaveBeenCalled(); @@ -58,10 +58,10 @@ describe("RoomNotificationStateStore", function () { // When we sync and the room is visible mocked(client.getVisibleRooms).mockReturnValue([room]); - client.emit(ClientEvent.Sync, null, null); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // Then we emit an event from the store - expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), null); + expect(store.emit).toHaveBeenCalledWith(UPDATE_STATUS_INDICATOR, expect.anything(), "SYNCING"); }); it("Emits an event when a feature flag changes notification state", async () => { @@ -69,7 +69,7 @@ describe("RoomNotificationStateStore", function () { let room = fakeRoom(0); mocked(store.emit).mockReset(); mocked(client.getVisibleRooms).mockReturnValue([room]); - client.emit(ClientEvent.Sync, null, null); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); expect(store.emit).not.toHaveBeenCalled(); // When we update the feature flag and it makes us have a notification @@ -91,7 +91,7 @@ describe("RoomNotificationStateStore", function () { it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { // When we sync mocked(client.getVisibleRooms).mockReturnValue([]); - client.emit(ClientEvent.Sync, null, null); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // Then we check visible rooms, using the dynamic predecessor flag expect(client.getVisibleRooms).toHaveBeenCalledWith(false); @@ -110,7 +110,7 @@ describe("RoomNotificationStateStore", function () { it("Passes the dynamic predecessor flag to getVisibleRooms", async () => { // When we sync mocked(client.getVisibleRooms).mockReturnValue([]); - client.emit(ClientEvent.Sync, null, null); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // Then we check visible rooms, using the dynamic predecessor flag expect(client.getVisibleRooms).toHaveBeenCalledWith(true); From 11f47068f5cabf304727c0bfcf6dcfc5544dca36 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 8 Mar 2023 13:08:35 +0000 Subject: [PATCH 7/7] Fix other tests that trigger our new watcher --- test/components/structures/RoomView-test.tsx | 18 +++++++++++++----- .../views/dialogs/SpotlightDialog-test.tsx | 10 ++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index f7425072ef8..bad6f27c667 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -203,11 +203,19 @@ describe("RoomView", () => { expect(instance.getHiddenHighlightCount()).toBe(23); }); - it("and feature_dynamic_room_predecessors is enabled it should pass the setting to findPredecessor", async () => { - SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, true); - expect(instance.getHiddenHighlightCount()).toBe(0); - expect(room.findPredecessor).toHaveBeenCalledWith(true); - SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, null); + describe("and feature_dynamic_room_predecessors is enabled", () => { + beforeEach(() => { + instance.setState({ msc3946ProcessDynamicPredecessor: true }); + }); + + afterEach(() => { + instance.setState({ msc3946ProcessDynamicPredecessor: false }); + }); + + it("should pass the setting to findPredecessor", async () => { + expect(instance.getHiddenHighlightCount()).toBe(0); + expect(room.findPredecessor).toHaveBeenCalledWith(true); + }); }); }); diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 276c42d5d56..86cf4018d06 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -198,11 +198,17 @@ describe("Spotlight Dialog", () => { describe("when MSC3946 dynamic room predecessors is enabled", () => { beforeEach(() => { - SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, true); + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, excludeDefault) => { + if (settingName === "feature_dynamic_room_predecessors") { + return true; + } else { + return []; // SpotlightSearch.recentSearches + } + }); }); afterEach(() => { - SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, null); + jest.restoreAllMocks(); }); it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => {