Page MenuHomePhorge

D188.1732583007.diff
No OneTemporary

Size
21 KB
Referenced Files
None
Subscribers
None

D188.1732583007.diff

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -173,6 +173,7 @@
ConfirmUploadPopup.qml
StickerPicker.qml
AddStickerPopup.qml
+ StickerPackNameProvider.qml
ConfirmationOverlay.qml
event-types/Simple.qml
event-types/Text.qml
diff --git a/src/contents/ui/AddStickerPopup.qml b/src/contents/ui/AddStickerPopup.qml
--- a/src/contents/ui/AddStickerPopup.qml
+++ b/src/contents/ui/AddStickerPopup.qml
@@ -19,7 +19,12 @@
property var stickerPackList
property var event
- property var currentPack: stickerPackList.at(0)
+ readonly property var availablePacks: stickerPackList.packs
+ readonly property var packNameProvider: Kazv.StickerPackNameProvider {
+ }
+ readonly property var availablePackNames: availablePacks.map(k => packNameProvider.getName(k))
+ readonly property alias packIndex: packChooser.currentIndex
+ property var currentPack: stickerPackList.at(packIndex)
property var stickerSize: Kirigami.Units.iconSizes.enormous
property var addingSticker: false
@@ -37,6 +42,15 @@
source: matrixSdk.mxcUriToHttp(event.content.url)
}
+ ComboBox {
+ id: packChooser
+ objectName: 'packChooser'
+ Layout.fillWidth: true
+ model: addStickerPopup.availablePackNames
+ currentIndex: 0
+ Kirigami.FormData.label: l10n.get('add-sticker-popup-pack-prompt')
+ }
+
TextField {
Layout.fillWidth: true
id: shortCodeInput
@@ -73,7 +87,7 @@
trigger: () => {
addStickerPopup.addingSticker = true;
return matrixSdk.updateStickerPack(
- currentPack.addSticker(shortCodeInput.text, addStickerPopup.event)
+ addStickerPopup.currentPack.addSticker(shortCodeInput.text, addStickerPopup.event)
);
}
onResolved: (success, data) => {
diff --git a/src/contents/ui/StickerPackNameProvider.qml b/src/contents/ui/StickerPackNameProvider.qml
new file mode 100644
--- /dev/null
+++ b/src/contents/ui/StickerPackNameProvider.qml
@@ -0,0 +1,43 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+
+import '.' as Kazv
+
+QtObject {
+ id: provider
+ property var pack
+ readonly property var name: getName(pack)
+ readonly property var roomNameProvider: Kazv.RoomNameProvider {
+ room: sdkVars.roomList.room(pack.roomId)
+ }
+
+ function getName(pack)
+ {
+ if (pack.packName) {
+ return pack.packName;
+ }
+
+ if (pack.isAccountData) {
+ return l10n.get('sticker-picker-user-stickers');
+ } else if (pack.isState) {
+ const roomName = pack === provider.pack
+ ? roomNameProvider.name
+ : roomNameProvider.getName(sdkVars.roomList.room(pack.roomId));
+ if (pack.stateKey) {
+ return l10n.get('sticker-picker-room-sticker-pack-name', {
+ stateKey: pack.stateKey,
+ room: roomName,
+ });
+ } else {
+ return l10n.get('sticker-picker-room-default-sticker-pack-name', {
+ room: roomName,
+ });
+ }
+ }
+ }
+}
diff --git a/src/contents/ui/StickerPicker.qml b/src/contents/ui/StickerPicker.qml
--- a/src/contents/ui/StickerPicker.qml
+++ b/src/contents/ui/StickerPicker.qml
@@ -29,10 +29,14 @@
Repeater {
model: stickerPackList
delegate: TabButton {
+ id: packDelegate
objectName: `stickerPack${index}`
property var pack: stickerPackList.at(index)
+ property var packNameProvider: Kazv.StickerPackNameProvider {
+ pack: packDelegate.pack
+ }
icon.name: 'smiley'
- text: pack.packName ? pack.packName : l10n.get('sticker-picker-user-stickers')
+ text: packNameProvider.name
checked: stickerPicker.currentIndex === index
onClicked: stickerPicker.currentIndex = index
}
diff --git a/src/l10n/cmn-Hans/100-ui.ftl b/src/l10n/cmn-Hans/100-ui.ftl
--- a/src/l10n/cmn-Hans/100-ui.ftl
+++ b/src/l10n/cmn-Hans/100-ui.ftl
@@ -152,6 +152,8 @@
send-message-box-stickers-popup-title = 发送贴纸
sticker-picker-user-stickers = 我的贴纸
+sticker-picker-room-sticker-pack-name = {$room} 中的 {$stateKey}
+sticker-picker-room-default-sticker-pack-name = {$room} 中的默认包
room-timeline-load-more-action = 加载更多
@@ -246,6 +248,7 @@
media-file-menu-add-sticker-action = 添加到贴纸...
add-sticker-popup-title = 添加到贴纸
+add-sticker-popup-pack-prompt = 添加到:
add-sticker-popup-short-code-prompt = 短代码:
add-sticker-popup-short-code-exists-warning = 贴纸包里已经有这个短代码了。上面的贴纸会覆盖已有的。
add-sticker-popup-add-sticker-button = 添加
diff --git a/src/l10n/en/100-ui.ftl b/src/l10n/en/100-ui.ftl
--- a/src/l10n/en/100-ui.ftl
+++ b/src/l10n/en/100-ui.ftl
@@ -156,6 +156,8 @@
send-message-box-stickers-popup-title = Send a sticker
sticker-picker-user-stickers = My stickers
+sticker-picker-room-sticker-pack-name = {$stateKey} in {$room}
+sticker-picker-room-default-sticker-pack-name = Default pack in {$room}
room-timeline-load-more-action = Load more
@@ -268,6 +270,7 @@
media-file-menu-add-sticker-action = Add to sticker...
add-sticker-popup-title = Add to sticker
+add-sticker-popup-pack-prompt = Add to pack:
add-sticker-popup-short-code-prompt = Short code:
add-sticker-popup-short-code-exists-warning = The short code already exists in this pack. The sticker above will override the existing one.
add-sticker-popup-add-sticker-button = Add
diff --git a/src/matrix-sdk.cpp b/src/matrix-sdk.cpp
--- a/src/matrix-sdk.cpp
+++ b/src/matrix-sdk.cpp
@@ -709,6 +709,14 @@
auto eventJson = std::move(source.event).raw().get();
eventJson["type"] = source.eventType;
return sendAccountDataImpl(Event(std::move(eventJson)));
+ } else if (source.source == MatrixStickerPackSource::RoomState) {
+ auto eventJson = std::move(source.event).raw().get();
+ eventJson["type"] = source.eventType;
+ eventJson["state_key"] = source.stateKey;
+ return new MatrixPromise(
+ m_d->clientOnSecondaryRoot
+ .room(source.roomId)
+ .sendStateEvent(Event(std::move(eventJson))));
} else {
return 0;
}
diff --git a/src/matrix-sticker-pack-list.hpp b/src/matrix-sticker-pack-list.hpp
--- a/src/matrix-sticker-pack-list.hpp
+++ b/src/matrix-sticker-pack-list.hpp
@@ -9,7 +9,7 @@
#include <QObject>
#include <QQmlEngine>
-
+#include <QJsonValue>
#include <immer/flex_vector.hpp>
#include <lager/reader.hpp>
@@ -39,4 +39,8 @@
~MatrixStickerPackList() override;
Q_INVOKABLE MatrixStickerPack *at(int index) const;
+
+ LAGER_QT_READER(QJsonValue, packs);
+
+ Q_INVOKABLE MatrixStickerPack *packFor(const QJsonValue &desc) const;
};
diff --git a/src/matrix-sticker-pack-list.cpp b/src/matrix-sticker-pack-list.cpp
--- a/src/matrix-sticker-pack-list.cpp
+++ b/src/matrix-sticker-pack-list.cpp
@@ -5,7 +5,9 @@
*/
#include <kazv-defs.hpp>
-
+#include <QJsonObject>
+#include <QJsonValue>
+#include <QJsonArray>
#include <lager/lenses/optional.hpp>
#include <lager/lenses/at.hpp>
@@ -14,6 +16,7 @@
#include "matrix-sticker-pack-list-p.hpp"
using namespace Kazv;
+using namespace Qt::Literals::StringLiterals;
using ImagePackRoomMap = immer::map<std::string/* room id */, immer::map<std::string/* */, json>>;
@@ -74,9 +77,48 @@
});
}
+static QString getPackDisplayName(const MatrixStickerPackSource &source)
+{
+ auto content = source.event.content().get();
+ if (content.contains("/pack/display_name"_json_pointer)
+ && content["/pack/display_name"_json_pointer].is_string()) {
+ return QString::fromStdString(
+ content["/pack/display_name"_json_pointer]
+ .template get<std::string>());
+ }
+ return u""_s;
+}
+
+static QJsonValue sourceToPacks(const immer::flex_vector<MatrixStickerPackSource> &sources)
+{
+ QJsonArray res;
+ for (const auto &source : sources) {
+ QJsonObject obj{
+ {u"source"_s, source.source},
+ {u"isAccountData"_s, source.source == MatrixStickerPackSource::AccountData},
+ {u"isState"_s, source.source == MatrixStickerPackSource::RoomState},
+ {u"eventType"_s, QString::fromStdString(source.eventType)},
+ {u"roomId"_s, QString::fromStdString(source.roomId)},
+ {u"stateKey"_s, QString::fromStdString(source.stateKey)},
+ {u"packName"_s, getPackDisplayName(source)},
+ };
+ res.append(obj);
+ }
+ return res;
+}
+
+static auto defaultSource = MatrixStickerPackSource{
+ MatrixStickerPackSource::AccountData,
+ accountDataEventType,
+ Event(),
+ "",
+ "",
+};
+
MatrixStickerPackList::MatrixStickerPackList(Client client, QObject *parent)
: KazvAbstractListModel(parent)
, m_events(getEventsFromClient(client))
+ , LAGER_QT(packs)(m_events.map(sourceToPacks))
{
initCountCursor(m_events.map([](const auto &events) {
return static_cast<int>(events.size());
@@ -86,6 +128,7 @@
MatrixStickerPackList::MatrixStickerPackList(Room room, QObject *parent)
: KazvAbstractListModel(parent)
, m_events(getEventsFromRoom(room))
+ , LAGER_QT(packs)(m_events.map(sourceToPacks))
{
initCountCursor(m_events.map([](const auto &events) {
return static_cast<int>(events.size());
@@ -100,13 +143,29 @@
if (events.size() > std::size_t(index)) {
return events[index];
} else {
- return MatrixStickerPackSource{
- MatrixStickerPackSource::AccountData,
- accountDataEventType,
- Event(),
- "",
- "",
- };
+ return defaultSource;
+ }
+ }));
+}
+
+MatrixStickerPack *MatrixStickerPackList::packFor(const QJsonValue &desc) const
+{
+ auto source = MatrixStickerPackSource::Source(desc.toObject()[u"source"_s].toInt());
+ auto eventType = desc.toObject()[u"eventType"_s].toString().toStdString();
+ auto roomId = desc.toObject()[u"roomId"_s].toString().toStdString();
+ auto stateKey = desc.toObject()[u"stateKey"_s].toString().toStdString();
+
+ return new MatrixStickerPack(m_events.map([=](const auto &events) {
+ auto it = std::find_if(events.begin(), events.end(), [=](const auto &s) {
+ return s.source == source
+ && s.eventType == eventType
+ && s.roomId == roomId
+ && s.stateKey == stateKey;
+ });
+ if (it == events.end()) {
+ return defaultSource;
+ } else {
+ return *it;
}
}));
}
diff --git a/src/tests/matrix-sticker-pack-test.cpp b/src/tests/matrix-sticker-pack-test.cpp
--- a/src/tests/matrix-sticker-pack-test.cpp
+++ b/src/tests/matrix-sticker-pack-test.cpp
@@ -9,7 +9,8 @@
#include <memory>
#include <QtTest>
-
+#include <QSet>
+#include <QJsonObject>
#include <lager/state.hpp>
#include <base/event.hpp>
@@ -195,6 +196,64 @@
QVERIFY(hasPack(packs, u"Pack 1"_s));
QVERIFY(hasPack(packs, u"Pack 2"_s));
QVERIFY(hasPack(packs, u"Pack 3"_s));
+
+ auto expectedPacks = QSet<QJsonValue>{
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::AccountData},
+ {u"isAccountData"_s, true},
+ {u"isState"_s, false},
+ {u"eventType"_s, u"im.ponies.user_emotes"_s},
+ {u"roomId"_s, u""_s},
+ {u"stateKey"_s, u""_s},
+ {u"packName"_s, u"Awesome Pack"_s},
+ },
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"isAccountData"_s, false},
+ {u"isState"_s, true},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someroom:example.org"_s},
+ {u"stateKey"_s, u""_s},
+ {u"packName"_s, u"Pack 1"_s},
+ },
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"isAccountData"_s, false},
+ {u"isState"_s, true},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someroom:example.org"_s},
+ {u"stateKey"_s, u"de.sorunome.mx-puppet-bridge.discord"_s},
+ {u"packName"_s, u"Pack 2"_s},
+ },
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"isAccountData"_s, false},
+ {u"isState"_s, true},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someotherroom:example.org"_s},
+ {u"stateKey"_s, u""_s},
+ {u"packName"_s, u"Pack 3"_s},
+ },
+ };
+
+ auto packsArr = stickerPackList->packs().toArray();
+ QCOMPARE(QSet<QJsonValue>(packsArr.begin(), packsArr.end()), expectedPacks);
+
+ auto p1 = toUniquePtr(stickerPackList->packFor(QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someroom:example.org"_s},
+ {u"stateKey"_s, u""_s},
+ }));
+ QCOMPARE(p1->packName(), u"Pack 1"_s);
+
+ auto p2 = toUniquePtr(stickerPackList->packFor(QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someotherroom:example.org"_s},
+ {u"stateKey"_s, u"somestatekey"_s},
+ }));
+ QVERIFY(p2->isAccountData());
}
void MatrixStickerPackTest::testStickerPackListInRoom()
@@ -221,6 +280,30 @@
QVERIFY(hasPack(packs, u"Pack 1"_s));
QVERIFY(hasPack(packs, u"Pack 2"_s));
+
+ auto expectedPacks = QSet<QJsonValue>{
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"isAccountData"_s, false},
+ {u"isState"_s, true},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someroom:example.org"_s},
+ {u"stateKey"_s, u""_s},
+ {u"packName"_s, u"Pack 1"_s},
+ },
+ QJsonObject{
+ {u"source"_s, MatrixStickerPackSource::RoomState},
+ {u"isAccountData"_s, false},
+ {u"isState"_s, true},
+ {u"eventType"_s, u"im.ponies.room_emotes"_s},
+ {u"roomId"_s, u"!someroom:example.org"_s},
+ {u"stateKey"_s, u"de.sorunome.mx-puppet-bridge.discord"_s},
+ {u"packName"_s, u"Pack 2"_s},
+ },
+ };
+
+ auto packsArr = stickerPackList->packs().toArray();
+ QCOMPARE(QSet<QJsonValue>(packsArr.begin(), packsArr.end()), expectedPacks);
}
void MatrixStickerPackTest::testAddToPack()
diff --git a/src/tests/quick-tests/tst_AddStickerPopup.qml b/src/tests/quick-tests/tst_AddStickerPopup.qml
--- a/src/tests/quick-tests/tst_AddStickerPopup.qml
+++ b/src/tests/quick-tests/tst_AddStickerPopup.qml
@@ -36,11 +36,18 @@
},
})
stickerPackList: ({
- at() {
+ at(index) {
return {
addSticker(shortCode, event) {
return {
- [shortCode]: event.content
+ _packIndex: index,
+ event: {
+ content: {
+ images: {
+ [shortCode]: event.content,
+ },
+ },
+ },
};
},
hasShortCode(shortCode) {
@@ -48,6 +55,10 @@
},
};
},
+ packs: [
+ { packName: 'pack0' },
+ { packName: 'pack1' },
+ ],
})
property var close: mockHelper.noop()
}
@@ -59,6 +70,8 @@
function init() {
addStickerPopup.contentItem.parent = item;
+ findChild(addStickerPopup, 'packChooser').currentIndex = 0;
+ addStickerPopup.addingSticker = false;
}
function cleanup() {
@@ -70,14 +83,13 @@
verify(!findChild(addStickerPopup, 'shortCodeExistsWarning').visible);
const button = findChild(addStickerPopup, 'addStickerButton');
verify(button.enabled);
- // tryVerify(() => false, 50000);
mouseClick(button);
tryVerify(() => matrixSdk.updateStickerPack.calledTimes() === 1);
- verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs(), [{
+ verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs()[0].event.content.images, {
'bar': {
url: 'mxc://example.org/something',
}
- }]));
+ }));
tryVerify(() => !button.enabled);
tryVerify(() => findChild(addStickerPopup, 'shortCodeInput').readOnly);
@@ -86,19 +98,30 @@
tryVerify(() => addStickerPopup.close.calledTimes() === 1);
}
+ function test_addStickerOtherPack() {
+ verify(Helpers.deepEqual(addStickerPopup.availablePackNames, ['pack0', 'pack1']));
+ findChild(addStickerPopup, 'packChooser').currentIndex = 1;
+ findChild(addStickerPopup, 'shortCodeInput').text = 'bar';
+ verify(!findChild(addStickerPopup, 'shortCodeExistsWarning').visible);
+ const button = findChild(addStickerPopup, 'addStickerButton');
+ verify(button.enabled);
+ mouseClick(button);
+ tryVerify(() => matrixSdk.updateStickerPack.calledTimes() === 1);
+ compare(matrixSdk.updateStickerPack.lastArgs()[0]._packIndex, 1);
+ }
+
function test_addStickerFailed() {
findChild(addStickerPopup, 'shortCodeInput').text = 'bar';
verify(!findChild(addStickerPopup, 'shortCodeExistsWarning').visible);
const button = findChild(addStickerPopup, 'addStickerButton');
verify(button.enabled);
- // tryVerify(() => false, 50000);
mouseClick(button);
tryVerify(() => matrixSdk.updateStickerPack.calledTimes() === 1);
- verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs(), [{
+ verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs()[0].event.content.images, {
'bar': {
url: 'mxc://example.org/something',
}
- }]));
+ }));
matrixSdk.updateStickerPack.lastRetVal().resolve(false, {});
tryVerify(() => button.enabled);
verify(addStickerPopup.close.calledTimes() === 0);
@@ -109,14 +132,13 @@
tryVerify(() => findChild(addStickerPopup, 'shortCodeExistsWarning').visible);
const button = findChild(addStickerPopup, 'addStickerButton');
verify(button.enabled);
- // tryVerify(() => false, 50000);
mouseClick(button);
tryVerify(() => matrixSdk.updateStickerPack.calledTimes() === 1);
- verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs(), [{
+ verify(Helpers.deepEqual(matrixSdk.updateStickerPack.lastArgs()[0].event.content.images, {
'foo': {
url: 'mxc://example.org/something',
}
- }]));
+ }));
tryVerify(() => !button.enabled);
tryVerify(() => findChild(addStickerPopup, 'shortCodeInput').readOnly);
diff --git a/src/tests/quick-tests/tst_StickerPackNameProvider.qml b/src/tests/quick-tests/tst_StickerPackNameProvider.qml
new file mode 100644
--- /dev/null
+++ b/src/tests/quick-tests/tst_StickerPackNameProvider.qml
@@ -0,0 +1,108 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2023 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+import QtTest 1.0
+
+import '../../contents/ui' as Kazv
+import './test-helpers' as TestHelpers
+import 'test-helpers.js' as Helpers
+
+TestCase {
+ id: stickerPackNameProviderTest
+ name: 'StickerPackNameProviderTest'
+ when: true
+
+ property var sdkVars: QtObject {
+ property var userGivenNicknameMap: QtObject {
+ property var map: ({})
+ }
+ property var roomList: QtObject {
+ function room(roomId) {
+ return stickerPackNameProviderTest.roomComp.createObject(stickerPackNameProviderTest, {
+ roomId,
+ name: `Some room ${roomId}`,
+ heroEvents: [],
+ });
+ }
+ }
+ }
+ property var l10n: Helpers.fluentMock
+
+ property var providerFunctional: Kazv.StickerPackNameProvider {}
+ property var providerReactive: Kazv.StickerPackNameProvider {}
+ property var roomComp: Component {
+ QtObject {
+ property var name
+ property var heroEvents
+ property var roomId
+ }
+ }
+ property var packComp: Component {
+ QtObject {
+ property var packName
+ property var isAccountData
+ property var isState
+ property var stateKey
+ property var roomId
+ }
+ }
+
+ function init() {
+ }
+
+ function test_functional() {
+ compare(
+ providerFunctional.getName({ packName: 'some name' }),
+ 'some name');
+
+ compare(providerFunctional.getName({
+ packName: '',
+ isAccountData: true,
+ }), l10n.get('sticker-picker-user-stickers'));
+
+ verify(
+ providerFunctional.getName({
+ packName: '',
+ isState: true,
+ stateKey: '',
+ }).includes('sticker-picker-room-default-sticker-pack-name'));
+ }
+
+ function test_reactiveAgainstPack() {
+ providerReactive.pack = stickerPackNameProviderTest.packComp.createObject(stickerPackNameProviderTest, {
+ packName: 'some name',
+ roomId: '!example:example.com',
+ isState: true,
+ isAccountData: false,
+ });
+
+ compare(providerReactive.name, 'some name');
+
+ providerReactive.pack.packName = 'some other name';
+ compare(providerReactive.name, 'some other name');
+
+ providerReactive.pack.packName = '';
+ verify(providerReactive.name.includes('Some room !example:example.com'));
+
+ providerReactive.pack.isAccountData = true;
+ providerReactive.pack.isState = false;
+ compare(providerReactive.name, l10n.get('sticker-picker-user-stickers'));
+ }
+
+ function test_reactiveAgainstRoom() {
+ providerReactive.pack = packComp.createObject(stickerPackNameProviderTest, {
+ packName: '',
+ roomId: '!example:example.com',
+ isState: true,
+ isAccountData: false,
+ });
+
+ providerReactive.roomNameProvider.room.name = 'Some other room';
+ verify(providerReactive.name.includes('Some other room'));
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Mon, Nov 25, 5:03 PM (9 h, 59 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
39905
Default Alt Text
D188.1732583007.diff (21 KB)

Event Timeline