Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F140535
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
25 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/contents/ui/UserPage.qml b/src/contents/ui/UserPage.qml
index 7a6d1ca..1718d3a 100644
--- a/src/contents/ui/UserPage.qml
+++ b/src/contents/ui/UserPage.qml
@@ -1,333 +1,364 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import QtQuick 2.2
-import QtQuick.Layouts 1.15
-import QtQuick.Controls 2.15
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
-import org.kde.kirigami 2.20 as Kirigami
-import moe.kazv.mxc.kazv 0.0 as MK
+import org.kde.kirigami as Kirigami
+import moe.kazv.mxc.kazv as MK
import '.' as Kazv
import 'device-mgmt' as KazvDM
import 'matrix-helpers.js' as Helpers
Kazv.ClosableScrollablePage {
id: userPage
property string userId: ''
property var user: ({})
property var nameProvider: Kazv.UserNameProvider {
user: userPage.user
}
property var room
property var roomNameProvider: Kazv.RoomNameProvider {
room: userPage.room
}
property var roomName: roomNameProvider.name
property var editingPowerLevel: false
property var submittingPowerLevel: false
property var powerLevelsLoaded: false
property var userPowerLevel: room.userPowerLevel(userId)
property var kickingUser: false
property var canKickUser: !(user.membership === 'ban' || user.membership === 'leave')
property var banningUser: false
property var unbanningUser: false
Connections {
target: room
function onPowerLevelsChanged() {
userPage.userPowerLevel = room.userPowerLevel(userPage.userId);
}
}
title: nameProvider.name
property var ensureMemberEvent: Kazv.AsyncHandler {
trigger: () => room.ensureStateEvent('m.room.member', userId)
}
property var ensurePowerLevels: Kazv.AsyncHandler {
trigger: () => room.ensureStateEvent('m.room.power_levels', '')
onResolved: (success, data) => {
if (success) {
userPage.powerLevelsLoaded = true;
}
}
}
property var updatingNameOverride: false
property var updateNameOverride: Kazv.AsyncHandler {
trigger: () => {
userPage.updatingNameOverride = true;
return sdkVars.userGivenNicknameMap.setAndUpload(
userPage.userId, nameOverrideInput.text || null);
}
onResolved: (success, data) => {
userPage.updatingNameOverride = false;
if (!success) {
showPassiveNotification(l10n.get('user-page-update-name-override-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
Component.onCompleted: {
userPage.ensureMemberEvent.call();
userPage.ensurePowerLevels.call();
}
property var setPowerLevel: Kazv.AsyncHandler {
trigger: () => {
userPage.submittingPowerLevel = true;
return room.setUserPowerLevel(userPage.userId, parseInt(newPowerLevel.text));
}
onResolved: (success, data) => {
if (!success) {
showPassiveNotification(l10n.get('user-page-set-power-level-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
} else {
userPage.editingPowerLevel = false;
}
userPage.submittingPowerLevel = false;
}
}
property var kickUser: Kazv.AsyncHandler {
trigger: () => {
userPage.kickingUser = true;
return room.kickUser(userPage.userId, kickUserReasonInput.text);
}
onResolved: {
if (!success) {
showPassiveNotification(l10n.get('user-page-kick-user-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
} else {
kickUserReasonInput.text = '';
}
userPage.kickingUser = false;
}
}
property var banUser: Kazv.AsyncHandler {
trigger: () => {
userPage.banningUser = true;
return room.banUser(userPage.userId, banUserReasonInput.text);
}
onResolved: {
if (!success) {
showPassiveNotification(l10n.get('user-page-ban-user-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
} else {
banUserReasonInput.text = '';
}
userPage.banningUser = false;
}
}
property var unbanUser: Kazv.AsyncHandler {
trigger: () => {
userPage.unbanningUser = true;
return room.unbanUser(userPage.userId);
}
onResolved: {
if (!success) {
showPassiveNotification(l10n.get('user-page-unban-user-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
userPage.unbanningUser = false;
}
}
ColumnLayout {
Kazv.AvatarAdapter {
id: avatar
objectName: 'avatar'
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: Kirigami.Units.iconSizes.enormous
Layout.preferredWidth: Kirigami.Units.iconSizes.enormous
source: userPage.user.avatarMxcUri ? matrixSdk.mxcUriToHttp(userPage.user.avatarMxcUri) : ''
sourceSize.width: Kirigami.Units.iconSizes.enormous
sourceSize.height: Kirigami.Units.iconSizes.enormous
name: nameProvider.name
}
+ ColumnLayout {
+ Layout.preferredWidth: this.parent.width
+ Label {
+ objectName: 'userNameLabel'
+ Layout.alignment: Qt.AlignHCenter
+ font.pixelSize: Kirigami.Units.gridUnit
+ text: !!userPage.user.name ? userPage.user.name : userPage.userId
+ }
+ Label {
+ objectName: 'userIdLabel'
+ Layout.alignment: Qt.AlignHCenter
+ font.pixelSize: Kirigami.Units.gridUnit * 0.8
+ visible: !!userPage.user.name
+ text: userPage.userId
+ }
+ }
+
RowLayout {
// Do not allow user to set a name override for themselves
visible: userPage.userId !== matrixSdk.userId
TextField {
id: nameOverrideInput
objectName: 'nameOverrideInput'
placeholderText: l10n.get('user-page-overrided-name-placeholder')
Layout.fillWidth: true
enabled: !userPage.updatingNameOverride
text: nameProvider.overridedName
}
Button {
objectName: 'saveNameOverrideButton'
text: l10n.get('user-page-save-name-override-action')
enabled: !userPage.updatingNameOverride
onClicked: updateNameOverride.call()
}
}
RowLayout {
visible: !userPage.editingPowerLevel
Label {
objectName: 'powerLevelLabel'
Layout.fillWidth: true
text: l10n.get('user-page-power-level', { powerLevel: userPage.userPowerLevel })
}
Button {
objectName: 'editPowerLevelButton'
text: l10n.get('user-page-edit-power-level-action')
enabled: userPage.powerLevelsLoaded
onClicked: {
userPage.editingPowerLevel = true;
}
}
}
RowLayout {
visible: userPage.editingPowerLevel
TextField {
objectName: 'newPowerLevelInput'
id: newPowerLevel
Layout.fillWidth: true
text: `${userPage.userPowerLevel}`
readOnly: userPage.submittingPowerLevel
}
Button {
objectName: 'savePowerLevelButton'
text: l10n.get('user-page-save-power-level-action')
onClicked: {
setPowerLevel.call();
}
enabled: !userPage.submittingPowerLevel
}
Button {
objectName: 'discardPowerLevelButton'
text: l10n.get('user-page-discard-power-level-action')
enabled: !userPage.submittingPowerLevel
onClicked: {
userPage.editingPowerLevel = false;
newPowerLevel.text = `${userPage.userPowerLevel}`;
}
}
}
ColumnLayout {
Kirigami.PromptDialog {
id: kickUserReasonDialog
objectName: 'kickUserReasonDialog'
title: l10n.get('user-page-kick-user-confirm-dialog-title')
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onAccepted: {
userPage.kickUser.call()
}
ColumnLayout {
Label {
Layout.fillWidth: true
text: l10n.get('user-page-kick-user-confirm-dialog-content', {
userId: userPage.user.userId,
name: nameProvider.name,
roomName: userPage.roomName,
})
wrapMode: Text.Wrap
}
- Kirigami.FormLayout {
- Layout.fillWidth: true
- TextField {
+ ColumnLayout {
+ Label {
Layout.fillWidth: true
- id: kickUserReasonInput
- objectName: 'kickUserReasonInput'
- readOnly: userPage.kickingUser
- Kirigami.FormData.label: l10n.get('user-page-kick-user-reason-prompt')
+ text: l10n.get('user-page-kick-user-confirm-dialog-content', {
+ userId: userPage.user.userId,
+ name: userPage.user.name,
+ roomName: userPage.roomName,
+ })
+ wrapMode: Text.Wrap
+ }
+
+ Kirigami.FormLayout {
+ Layout.fillWidth: true
+ TextField {
+ Layout.fillWidth: true
+ id: kickUserReasonInput
+ objectName: 'kickUserReasonInput'
+ readOnly: userPage.kickingUser
+ Kirigami.FormData.label: l10n.get('user-page-kick-user-reason-prompt')
+ }
}
}
}
}
-
Button {
objectName: 'kickUserButton'
+ icon.name: 'im-kick-user'
Layout.fillWidth: true
enabled: !userPage.kickingUser
text: l10n.get('user-page-kick-user-action')
visible: userPage.canKickUser
onClicked: {
kickUserReasonDialog.open()
}
}
}
RowLayout {
Kirigami.PromptDialog {
id: banUserReasonDialog
objectName: 'banUserReasonDialog'
title: l10n.get('user-page-ban-user-confirm-dialog-title')
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onAccepted: {
userPage.banUser.call()
}
ColumnLayout {
Label {
Layout.fillWidth: true
text: l10n.get('user-page-ban-user-confirm-dialog-content', {
userId: userPage.user.userId,
name: nameProvider.name,
roomName: userPage.roomName,
})
wrapMode: Text.Wrap
}
Kirigami.FormLayout {
Layout.fillWidth: true
TextField {
Layout.fillWidth: true
id: banUserReasonInput
objectName: 'banUserReasonInput'
readOnly: userPage.banningUser
Kirigami.FormData.label: l10n.get('user-page-ban-user-reason-prompt')
}
}
}
}
Button {
objectName: 'banUserButton'
Layout.fillWidth: true
+ icon.name: 'im-ban-kick-user'
enabled: !userPage.banningUser
text: l10n.get('user-page-ban-user-action')
visible: !unbanUserButton.visible
onClicked: {
banUserReasonDialog.open()
}
}
Button {
id: unbanUserButton
objectName: 'unbanUserButton'
Layout.fillWidth: true
+ icon.name: 'im-user-online'
enabled: !userPage.unbanningUser
text: l10n.get('user-page-unban-user-action')
visible: user.membership === "ban"
onClicked: {
userPage.unbanUser.call()
}
}
}
KazvDM.DeviceList {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: childrenRect.height
userId: userPage.userId
devices: matrixSdk.devicesOfUser(userId)
}
}
}
diff --git a/src/tests/quick-tests/tst_UserPage.qml b/src/tests/quick-tests/tst_UserPage.qml
index f9b4cfd..62ce23b 100644
--- a/src/tests/quick-tests/tst_UserPage.qml
+++ b/src/tests/quick-tests/tst_UserPage.qml
@@ -1,342 +1,366 @@
/*
* 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 JsHelpers
import moe.kazv.mxc.kazv 0.0 as MK
Item {
id: upper
width: 800
height: 600
property var l10n: JsHelpers.fluentMock
property var matrixSdk: TestHelpers.MatrixSdkMock {
property var userId: '@foo:example.com'
}
property var mockHelper: TestHelpers.MockHelper {}
property var sdkVars: QtObject {
property var userGivenNicknameMap: QtObject {
id: gnMap
property var map: ({})
property var setAndUpload: mockHelper.promise()
}
}
property var showPassiveNotification: mockHelper.noop()
property var powerLevelMapping: ({
'@mew:example.com': 100,
})
property var room: QtObject {
signal onPowerLevelsChanged()
function userPowerLevel(userId) {
return powerLevelMapping[userId];
}
property var setUserPowerLevel: mockHelper.promise()
property var ensureStateEvent: mockHelper.promise()
property var kickUser: mockHelper.promise()
property var banUser: mockHelper.promise()
property var unbanUser: mockHelper.promise()
}
property var userPage: null
property var userPageComp: Component {
Kazv.UserPage {
userId: '@mew:example.com'
user: ({ userId: '@mew:example.com', name: 'mew' })
room: upper.room
}
}
+ property var userPageWithoutName: null
+ property var userPageWithoutNameComp: Component {
+ Kazv.UserPage {
+ userId: '@mew:example.com'
+ user: ({ userId: '@mew:example.com', name: '' })
+ }
+ }
+
ColumnLayout {
id: layout
}
TestCase {
id: userPageTest
name: 'UserPageTest'
when: windowShown
function init() {
mockHelper.clearAll();
userPage = userPageComp.createObject(layout);
- layout.children = [userPage];
+ userPageWithoutName = userPageWithoutNameComp.createObject(layout);
+ layout.children = [userPage, userPageWithoutName];
if (MK.KazvUtil.kfQtMajorVersion === 6) {
userPage.contentItem.clip = false;
}
}
function cleanup() {
layout.children = [];
userPage.destroy();
userPage = null;
gnMap.map = {};
}
function test_powerLevel() {
verify(findChild(userPage, 'powerLevelLabel').visible);
verify(findChild(userPage, 'powerLevelLabel').text.includes('100'));
verify(!findChild(userPage, 'newPowerLevelInput').visible);
const editButton = findChild(userPage, 'editPowerLevelButton');
verify(editButton.visible);
verify(!editButton.enabled);
tryVerify(() => userPage.room.ensureStateEvent.calledTimes() === 2);
tryVerify(() => userPage.room.ensureStateEvent.args[1][0] === 'm.room.power_levels', 1000);
tryVerify(() => userPage.room.ensureStateEvent.retVals[1], 1000);
userPage.room.ensureStateEvent.retVals[1].resolve(true, {});
tryVerify(() => userPage.powerLevelsLoaded);
tryVerify(() => editButton.enabled);
tryVerify(() => editButton.visible);
mouseClick(editButton);
const textField = findChild(userPage, 'newPowerLevelInput');
tryVerify(() => textField.visible);
textField.text = '50';
const saveButton = findChild(userPage, 'savePowerLevelButton');
verify(saveButton.visible);
mouseClick(saveButton);
tryVerify(() => room.setUserPowerLevel.calledTimes(), 1000);
tryVerify(() => room.setUserPowerLevel.lastRetVal(), 1000);
// Text field is not closed until the request succeeded
verify(textField.visible);
verify(textField.readOnly);
verify(room.setUserPowerLevel.lastArgs()[0] === '@mew:example.com');
verify(room.setUserPowerLevel.lastArgs()[1] === 50);
// the buttons are disabled until the request responded
verify(!saveButton.enabled);
verify(!findChild(userPage, 'discardPowerLevelButton').enabled);
room.setUserPowerLevel.lastRetVal().resolve(true, {});
tryVerify(() => !textField.visible, 1000);
// Text field is editable when we try to edit the power level again
mouseClick(editButton);
tryVerify(() => textField.visible, 1000);
verify(!textField.readOnly);
}
function test_powerLevelEditFailure() {
tryVerify(() => userPage.room.ensureStateEvent.retVals[1], 1000);
userPage.room.ensureStateEvent.retVals[1].resolve(true, {});
const editButton = findChild(userPage, 'editPowerLevelButton');
tryVerify(() => editButton.enabled);
mouseClick(editButton);
const textField = findChild(userPage, 'newPowerLevelInput');
tryVerify(() => textField.visible, 1000);
textField.text = '50';
const saveButton = findChild(userPage, 'savePowerLevelButton');
mouseClick(saveButton);
tryVerify(() => room.setUserPowerLevel.lastRetVal(), 1000);
// Text field is not closed until the request succeeded
room.setUserPowerLevel.lastRetVal().resolve(false, {});
// text field is no longer readonly if failed
tryVerify(() => !textField.readOnly, 1000);
// show error message
verify(upper.showPassiveNotification.calledTimes());
// the buttons are enabled when the request responded
verify(saveButton.enabled);
verify(findChild(userPage, 'discardPowerLevelButton').enabled);
}
function test_banUser() {
// Ban user is only available if membership ! = "ban"
userPage.user = ({ membership: "join", userId: '@mew:example.com' });
const banButton = findChild(userPage, 'banUserButton');
verify(banButton.visible);
verify(banButton.enabled);
const reasonDialog = findChild(userPage, 'banUserReasonDialog');
verify(!reasonDialog.opened);
const textField = findChild(userPage, 'banUserReasonInput');
mouseClick(banButton);
verify(!reasonDialog.opened);
verify(textField.visible);
verify(!textField.readOnly);
textField.text = 'some reason';
reasonDialog.accept();
tryVerify(() => !reasonDialog.opened, 1000);
tryVerify(() => !banButton.enabled, 1000);
tryVerify(() => room.banUser.lastRetVal(), 1000);
verify(room.banUser.lastArgs()[0] === '@mew:example.com');
verify(room.banUser.lastArgs()[1] === 'some reason');
room.banUser.lastRetVal().resolve(true, {});
tryVerify(() => textField.text === '', 1000);
tryVerify(() => banButton.enabled, 1000);
}
function test_banUserFailure() {
// Ban user is only available if membership ! = "ban"
userPage.user = ({ membership: "join" });
const banButton = findChild(userPage, 'banUserButton');
verify(banButton.visible);
verify(banButton.enabled);
const reasonDialog = findChild(userPage, 'banUserReasonDialog');
verify(!reasonDialog.opened);
const textField = findChild(userPage, 'banUserReasonInput');
mouseClick(banButton);
verify(!reasonDialog.opened);
verify(textField.visible);
verify(!textField.readOnly);
textField.text = 'some reason';
reasonDialog.accept();
tryVerify(() => !reasonDialog.opened, 1000);
tryVerify(() => !banButton.enabled, 1000);
tryVerify(() => room.banUser.lastRetVal(), 1000);
verify(room.banUser.lastArgs()[0] === '@mew:example.com');
verify(room.banUser.lastArgs()[1] === 'some reason');
room.banUser.lastRetVal().resolve(false, {});
tryVerify(() => upper.showPassiveNotification.calledTimes(), 1000);
// text field is not cleared when failed
tryVerify(() => textField.text === 'some reason', 1000);
tryVerify(() => banButton.enabled, 1000);
}
function test_unbanUser() {
// Unban user is only available if membership == "ban"
userPage.user = ({ membership: "ban" });
const unbanButton = findChild(userPage, 'unbanUserButton');
verify(unbanButton.visible);
verify(unbanButton.enabled);
mouseClick(unbanButton);
tryVerify(() => !unbanButton.enabled, 1000);
verify(room.unbanUser.lastArgs()[0] == '@mew:example.com');
verify(room.unbanUser.lastRetVal());
room.unbanUser.lastRetVal().resolve(true, {});
tryVerify(() => unbanButton.enabled, 1000);
}
function test_unbanUserFailure() {
// Unban user is only available if membership == "ban"
userPage.user = ({ membership: "ban" });
const unbanButton = findChild(userPage, 'unbanUserButton');
verify(unbanButton.visible);
verify(unbanButton.enabled);
mouseClick(unbanButton);
tryVerify(() => !unbanButton.enabled, 1000);
verify(room.unbanUser.lastArgs()[0] == '@mew:example.com');
verify(room.unbanUser.lastRetVal());
room.unbanUser.lastRetVal().resolve(false, {});
tryVerify(() => upper.showPassiveNotification.calledTimes(), 1000);
tryVerify(() => unbanButton.enabled, 1000);
}
function test_kickUser() {
const kickButton = findChild(userPage, 'kickUserButton');
verify(kickButton.visible);
verify(kickButton.enabled);
const reasonDialog = findChild(userPage, 'kickUserReasonDialog');
verify(!reasonDialog.opened);
const textField = findChild(userPage, 'kickUserReasonInput');
mouseClick(kickButton);
verify(!reasonDialog.opened);
verify(!textField.readOnly);
textField.text = 'some reason';
reasonDialog.accept();
tryVerify(() => !reasonDialog.opened, 1000);
tryVerify(() => !kickButton.enabled, 1000);
tryVerify(() => room.kickUser.lastRetVal(), 1000);
verify(room.kickUser.lastArgs()[0] === '@mew:example.com');
verify(room.kickUser.lastArgs()[1] === 'some reason');
room.kickUser.lastRetVal().resolve(true, {});
tryVerify(() => textField.text === '', 1000);
// still available only because this is a mock call, no real action
// is performed on the user
tryVerify(() => kickButton.enabled, 1000);
}
function test_overridedName() {
const input = findChild(userPage, 'nameOverrideInput');
compare(input.text, '');
gnMap.map = { '@mew:example.com': 'something' };
tryCompare(input, 'text', 'something');
}
function test_noOverridedNameForSelf() {
userPage.userId = '@foo:example.com';
verify(!findChild(userPage, 'nameOverrideInput').visible);
}
function test_updateOverridedName() {
const input = findChild(userPage, 'nameOverrideInput');
compare(input.text, '');
verify(input.enabled);
input.text = 'something';
const saveButton = findChild(userPage, 'saveNameOverrideButton');
verify(saveButton.enabled);
mouseClick(saveButton);
tryVerify(() => !input.enabled);
verify(!saveButton.enabled);
compare(gnMap.setAndUpload.calledTimes(), 1);
verify(JsHelpers.deepEqual(gnMap.setAndUpload.lastArgs(), ['@mew:example.com', 'something']));
gnMap.setAndUpload.lastRetVal().resolve(true, {});
tryVerify(() => input.enabled);
verify(saveButton.enabled);
}
function test_updateOverridedNameFailed() {
const input = findChild(userPage, 'nameOverrideInput');
compare(input.text, '');
verify(input.enabled);
input.text = 'something';
const saveButton = findChild(userPage, 'saveNameOverrideButton');
verify(saveButton.enabled);
mouseClick(saveButton);
tryVerify(() => !input.enabled);
verify(!saveButton.enabled);
compare(gnMap.setAndUpload.calledTimes(), 1);
verify(JsHelpers.deepEqual(gnMap.setAndUpload.lastArgs(), ['@mew:example.com', 'something']));
gnMap.setAndUpload.lastRetVal().resolve(false, {});
tryVerify(() => input.enabled);
verify(saveButton.enabled);
}
function test_removeOverridedName() {
gnMap.map = { '@mew:example.com': 'something' };
const input = findChild(userPage, 'nameOverrideInput');
compare(input.text, 'something');
input.text = '';
const saveButton = findChild(userPage, 'saveNameOverrideButton');
verify(saveButton.enabled);
mouseClick(saveButton);
tryVerify(() => !input.enabled);
verify(!saveButton.enabled);
compare(gnMap.setAndUpload.calledTimes(), 1);
verify(JsHelpers.deepEqual(gnMap.setAndUpload.lastArgs(), ['@mew:example.com', null]));
gnMap.setAndUpload.lastRetVal().resolve(true, {});
tryVerify(() => input.enabled);
verify(saveButton.enabled);
}
+
+ function test_showUserNameAndId() {
+ const nameLabel = findChild(userPage, 'userNameLabel');
+ const idLabel = findChild(userPage, 'userIdLabel');
+ verify(nameLabel.text === 'mew');
+ verify(idLabel.text === '@mew:example.com');
+ verify(idLabel.visible);
+ }
+
+ function test_showUserIdOnly() {
+ const nameLabel = findChild(userPageWithoutName, 'userNameLabel');
+ const idLabel = findChild(userPageWithoutName, 'userIdLabel');
+ verify(nameLabel.text === '@mew:example.com');
+ verify(!idLabel.visible);
+ }
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jan 19, 11:53 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
55528
Default Alt Text
(25 KB)
Attached To
Mode
rK kazv
Attached
Detach File
Event Timeline
Log In to Comment