Improved channel private conversation mode behaviour
parent
a05b795859
commit
e6bbb883e4
|
@ -2,6 +2,7 @@
|
|||
* **09.12.20**
|
||||
- Fixed the private messages unread indicator
|
||||
- Properly updating the private message unread count
|
||||
- Improved channel conversation mode detection support
|
||||
|
||||
* **08.12.20**
|
||||
- Fixed the permission editor not resolving unique ids
|
||||
|
|
|
@ -2,15 +2,17 @@ import {
|
|||
ChatEvent,
|
||||
ChatEventMessage,
|
||||
ChatMessage,
|
||||
ChatState, ConversationHistoryResponse
|
||||
ChatState,
|
||||
ConversationHistoryResponse
|
||||
} from "tc-shared/ui/frames/side/ConversationDefinitions";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||
import {preprocessChatMessageForSend} from "tc-shared/text/chat";
|
||||
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import {ChannelConversation} from "tc-shared/conversations/ChannelConversationManager";
|
||||
import {ChannelConversationMode} from "tc-shared/tree/Channel";
|
||||
import {guid} from "tc-shared/crypto/uid";
|
||||
|
||||
export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */
|
||||
|
||||
|
@ -34,6 +36,12 @@ export interface AbstractChatEvents {
|
|||
},
|
||||
notify_history_state_changed: {
|
||||
hasHistory: boolean
|
||||
},
|
||||
notify_conversation_mode_changed: {
|
||||
newMode: ChannelConversationMode
|
||||
},
|
||||
notify_read_state_changed: {
|
||||
readable: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,13 +58,14 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
protected failedPermission: string;
|
||||
protected errorMessage: string;
|
||||
|
||||
protected conversationPrivate: boolean = false;
|
||||
private conversationMode: ChannelConversationMode;
|
||||
protected crossChannelChatSupported: boolean = true;
|
||||
|
||||
protected unreadTimestamp: number;
|
||||
protected unreadState: boolean = false;
|
||||
|
||||
protected messageSendEnabled: boolean = true;
|
||||
private conversationReadable = true;
|
||||
|
||||
private history = false;
|
||||
|
||||
|
@ -65,6 +74,7 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
this.connection = connection;
|
||||
this.chatId = chatId;
|
||||
this.unreadTimestamp = Date.now();
|
||||
this.conversationMode = ChannelConversationMode.Public;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
@ -103,7 +113,7 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
this.setUnreadTimestamp(Date.now());
|
||||
} else if(!this.isUnread() && triggerUnread) {
|
||||
this.setUnreadTimestamp(event.message.timestamp - 1);
|
||||
} else {
|
||||
} else if(!this.isUnread()) {
|
||||
/* mark the last message as read */
|
||||
this.setUnreadTimestamp(event.message.timestamp);
|
||||
}
|
||||
|
@ -119,7 +129,7 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
|
||||
if(!this.isUnread() && triggerUnread) {
|
||||
this.setUnreadTimestamp(event.timestamp - 1);
|
||||
} else {
|
||||
} else if(!this.isUnread()) {
|
||||
/* mark the last message as read */
|
||||
this.setUnreadTimestamp(event.timestamp);
|
||||
}
|
||||
|
@ -197,8 +207,41 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
return this.unreadState;
|
||||
}
|
||||
|
||||
public getConversationMode() : ChannelConversationMode {
|
||||
return this.conversationMode;
|
||||
}
|
||||
|
||||
public isPrivate() : boolean {
|
||||
return this.conversationPrivate;
|
||||
return this.conversationMode === ChannelConversationMode.Private;
|
||||
}
|
||||
|
||||
protected setConversationMode(mode: ChannelConversationMode) {
|
||||
if(this.conversationMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerChatEvent({
|
||||
type: "mode-changed",
|
||||
uniqueId: guid() + "-mode-change",
|
||||
timestamp: Date.now(),
|
||||
newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none"
|
||||
}, true);
|
||||
|
||||
this.conversationMode = mode;
|
||||
this.events.fire("notify_conversation_mode_changed", { newMode: mode });
|
||||
}
|
||||
|
||||
public isReadable() {
|
||||
return this.conversationReadable;
|
||||
}
|
||||
|
||||
protected setReadable(flag: boolean) {
|
||||
if(this.conversationReadable === flag) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.conversationReadable = flag;
|
||||
this.events.fire("notify_read_state_changed", { readable: flag });
|
||||
}
|
||||
|
||||
public isSendEnabled() : boolean {
|
||||
|
@ -275,7 +318,6 @@ export abstract class AbstractChat<Events extends AbstractChatEvents> {
|
|||
this.events.fire("notify_send_toggle", { enabled: enabled });
|
||||
}
|
||||
|
||||
public abstract canClientAccessChat() : boolean;
|
||||
public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse>;
|
||||
public abstract queryCurrentMessages();
|
||||
public abstract sendMessage(text: string);
|
||||
|
|
|
@ -15,6 +15,7 @@ import {traj} from "tc-shared/i18n/localize";
|
|||
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||
import {LocalClientEntry} from "tc-shared/tree/Client";
|
||||
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
|
||||
import {ChannelConversationMode} from "tc-shared/tree/Channel";
|
||||
|
||||
export interface ChannelConversationEvents extends AbstractChatEvents {
|
||||
notify_messages_deleted: { messages: string[] },
|
||||
|
@ -40,13 +41,12 @@ export class ChannelConversation extends AbstractChat<ChannelConversationEvents>
|
|||
this.conversationId = id;
|
||||
|
||||
this.preventUnreadUpdate = true;
|
||||
const dateNow = Date.now();
|
||||
const unreadTimestamp = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
|
||||
this.setUnreadTimestamp(unreadTimestamp);
|
||||
this.preventUnreadUpdate = false;
|
||||
|
||||
this.events.on("notify_unread_state_changed", event => {
|
||||
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(event.unread);
|
||||
this.events.on(["notify_unread_state_changed", "notify_read_state_changed"], event => {
|
||||
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(this.isReadable() && this.isUnread());
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -142,7 +142,6 @@ export class ChannelConversation extends AbstractChat<ChannelConversationEvents>
|
|||
this.setCurrentMode("loading");
|
||||
|
||||
this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => {
|
||||
this.conversationPrivate = false;
|
||||
this.conversationVolatile = false;
|
||||
this.failedPermission = undefined;
|
||||
this.errorMessage = undefined;
|
||||
|
@ -166,17 +165,18 @@ export class ChannelConversation extends AbstractChat<ChannelConversationEvents>
|
|||
break;
|
||||
|
||||
case "private":
|
||||
this.conversationPrivate = true;
|
||||
this.setConversationMode(ChannelConversationMode.Private);
|
||||
this.setCurrentMode("normal");
|
||||
break;
|
||||
|
||||
case "success":
|
||||
this.setConversationMode(ChannelConversationMode.Public);
|
||||
this.setCurrentMode("normal");
|
||||
break;
|
||||
|
||||
case "unsupported":
|
||||
this.crossChannelChatSupported = false;
|
||||
this.conversationPrivate = true;
|
||||
this.setConversationMode(ChannelConversationMode.Private);
|
||||
this.setCurrentMode("normal");
|
||||
break;
|
||||
}
|
||||
|
@ -295,6 +295,10 @@ export class ChannelConversation extends AbstractChat<ChannelConversationEvents>
|
|||
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp);
|
||||
}
|
||||
|
||||
public setConversationMode(mode: ChannelConversationMode) {
|
||||
super.setConversationMode(mode);
|
||||
}
|
||||
|
||||
public localClientSwitchedChannel(type: "join" | "leave") {
|
||||
this.registerChatEvent({
|
||||
type: "local-user-switch",
|
||||
|
@ -309,6 +313,14 @@ export class ChannelConversation extends AbstractChat<ChannelConversationEvents>
|
|||
sendMessage(text: string) {
|
||||
this.doSendMessage(text, this.conversationId ? 2 : 3, this.conversationId).then(() => {});
|
||||
}
|
||||
|
||||
updateAccessState() {
|
||||
if(this.isPrivate()) {
|
||||
this.setReadable(this.connection.getClient().currentChannel()?.getChannelId() === this.conversationId);
|
||||
} else {
|
||||
this.setReadable(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelConversationManagerEvents extends AbstractChatManagerEvents<ChannelConversation> { }
|
||||
|
@ -337,6 +349,37 @@ export class ChannelConversationManager extends AbstractChatManager<ChannelConve
|
|||
}
|
||||
}));
|
||||
|
||||
this.listenerConnection.push(connection.channelTree.events.on("notify_channel_updated", event => {
|
||||
const conversation = this.findConversation(event.channel.channelId);
|
||||
if(!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if("channel_conversation_mode" in event.updatedProperties) {
|
||||
conversation.setConversationMode(event.channel.properties.channel_conversation_mode);
|
||||
conversation.updateAccessState();
|
||||
}
|
||||
}));
|
||||
|
||||
this.listenerConnection.push(connection.channelTree.events.on("notify_client_moved", event => {
|
||||
if(event.client instanceof LocalClientEntry) {
|
||||
const fromConversation = this.findConversation(event.oldChannel.channelId);
|
||||
const targetConversation = this.findConversation(event.newChannel.channelId);
|
||||
|
||||
fromConversation?.updateAccessState();
|
||||
targetConversation?.updateAccessState();
|
||||
}
|
||||
}));
|
||||
|
||||
this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => {
|
||||
if(event.client instanceof LocalClientEntry) {
|
||||
const targetConversation = this.findConversation(event.targetChannel.channelId);
|
||||
targetConversation?.updateAccessState();
|
||||
}
|
||||
}));
|
||||
|
||||
/* TODO: Permission listener for text send power! */
|
||||
|
||||
this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
|
||||
this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this)));
|
||||
this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
|
||||
|
|
|
@ -42,6 +42,12 @@ export enum ChannelSubscribeMode {
|
|||
INHERITED
|
||||
}
|
||||
|
||||
export enum ChannelConversationMode {
|
||||
Public = 0,
|
||||
Private = 1,
|
||||
None = 2
|
||||
}
|
||||
|
||||
export class ChannelProperties {
|
||||
channel_order: number = 0;
|
||||
channel_name: string = "";
|
||||
|
@ -73,7 +79,7 @@ export class ChannelProperties {
|
|||
//Only after request
|
||||
channel_description: string = "";
|
||||
|
||||
channel_conversation_mode: number = 0; /* 0 := Private, the default */
|
||||
channel_conversation_mode: ChannelConversationMode = 0;
|
||||
channel_conversation_history_length: number = -1;
|
||||
}
|
||||
|
||||
|
@ -564,6 +570,8 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
}
|
||||
/* devel-block-end */
|
||||
|
||||
/* TODO: Validate values. Example: channel_conversation_mode */
|
||||
|
||||
for(let variable of variables) {
|
||||
let key = variable.key;
|
||||
let value = variable.value;
|
||||
|
|
|
@ -35,6 +35,8 @@ export abstract class AbstractConversationController<
|
|||
protected currentSelectedConversation: ConversationType;
|
||||
protected currentSelectedListener: (() => void)[];
|
||||
|
||||
protected crossChannelChatSupported = true;
|
||||
|
||||
protected constructor(conversationManager: Manager) {
|
||||
this.uiEvents = new Registry<Events>();
|
||||
this.currentSelectedListener = [];
|
||||
|
@ -86,6 +88,10 @@ export abstract class AbstractConversationController<
|
|||
this.currentSelectedListener.push(conversation.events.on("notify_history_state_changed", () => {
|
||||
this.reportStateToUI(conversation);
|
||||
}));
|
||||
|
||||
this.currentSelectedListener.push(conversation.events.on("notify_read_state_changed", () => {
|
||||
this.reportStateToUI(conversation);
|
||||
}));
|
||||
}
|
||||
|
||||
handlePanelShow() {
|
||||
|
@ -93,8 +99,6 @@ export abstract class AbstractConversationController<
|
|||
}
|
||||
|
||||
protected reportStateToUI(conversation: AbstractChat<any>) {
|
||||
const crossChannelChatSupported = true; /* FIXME: Determine this form the server! */
|
||||
|
||||
let historyState: ChatHistoryState;
|
||||
const localHistoryState = this.historyUiStates[conversation.getChatId()];
|
||||
if(!localHistoryState) {
|
||||
|
@ -113,11 +117,11 @@ export abstract class AbstractConversationController<
|
|||
|
||||
switch (conversation.getCurrentMode()) {
|
||||
case "normal":
|
||||
if(conversation.isPrivate() && !conversation.canClientAccessChat()) {
|
||||
if(conversation.isPrivate() && !conversation.isReadable()) {
|
||||
this.uiEvents.fire_react("notify_conversation_state", {
|
||||
chatId: conversation.getChatId(),
|
||||
state: "private",
|
||||
crossChannelChatSupported: crossChannelChatSupported
|
||||
crossChannelChatSupported: this.crossChannelChatSupported
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -133,7 +137,7 @@ export abstract class AbstractConversationController<
|
|||
chatFrameMaxMessageCount: kMaxChatFrameMessageSize,
|
||||
unreadTimestamp: conversation.getUnreadTimestamp(),
|
||||
|
||||
showUserSwitchEvents: conversation.isPrivate() || !crossChannelChatSupported,
|
||||
showUserSwitchEvents: conversation.isPrivate() || !this.crossChannelChatSupported,
|
||||
sendEnabled: conversation.isSendEnabled(),
|
||||
|
||||
events: [...conversation.getPresentEvents(), ...conversation.getPresentMessages()]
|
||||
|
@ -230,6 +234,18 @@ export abstract class AbstractConversationController<
|
|||
return this.currentSelectedConversation;
|
||||
}
|
||||
|
||||
protected setCrossChannelChatSupport(flag: boolean) {
|
||||
if(this.crossChannelChatSupported === flag) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.crossChannelChatSupported = flag;
|
||||
const currentConversation = this.getCurrentConversation();
|
||||
if(currentConversation) {
|
||||
this.reportStateToUI(this.getCurrentConversation());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("query_conversation_state")
|
||||
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
|
||||
const conversation = this.conversationManager.findConversationById(event.chatId);
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
ChatEventPartnerAction,
|
||||
ChatHistoryState,
|
||||
ChatMessage,
|
||||
ConversationUIEvents
|
||||
ConversationUIEvents, ChatEventModeChanged
|
||||
} from "tc-shared/ui/frames/side/ConversationDefinitions";
|
||||
import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer";
|
||||
import {BBCodeRenderer} from "tc-shared/text/bbcode";
|
||||
|
@ -277,6 +277,34 @@ const ChatEventPartnerActionRenderer = (props: { event: ChatEventPartnerAction,
|
|||
return null;
|
||||
};
|
||||
|
||||
const ChatEventModeChangedRenderer = (props: { event: ChatEventModeChanged, refHTMLElement: Ref<HTMLDivElement> }) => {
|
||||
switch (props.event.newMode) {
|
||||
case "none":
|
||||
return (
|
||||
<div className={cssStyle.containerSwitch + " " + cssStyle.actionClose} ref={props.refHTMLElement}>
|
||||
<a><Translatable>The conversation has been disabled</Translatable></a>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "private":
|
||||
return (
|
||||
<div className={cssStyle.containerSwitch + " " + cssStyle.actionClose} ref={props.refHTMLElement}>
|
||||
<a><Translatable>The conversation has been made private</Translatable></a>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "normal":
|
||||
return (
|
||||
<div className={cssStyle.containerSwitch + " " + cssStyle.actionClose} ref={props.refHTMLElement}>
|
||||
<a><Translatable>The conversation has been made public</Translatable></a>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const PartnerTypingIndicator = (props: { events: Registry<ConversationUIEvents>, chatId: string, timeout?: number }) => {
|
||||
const kTypingTimeout = props.timeout || 5000;
|
||||
|
||||
|
@ -658,6 +686,14 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
|
|||
refHTMLElement={reference}
|
||||
/>);
|
||||
break;
|
||||
|
||||
case "mode-changed":
|
||||
this.viewEntries.push(<ChatEventModeChangedRenderer
|
||||
key={event.uniqueId}
|
||||
event={event}
|
||||
refHTMLElement={reference}
|
||||
/>);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import * as React from "react";
|
||||
import {ConnectionHandler} from "../../../ConnectionHandler";
|
||||
import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler";
|
||||
import {EventHandler} from "../../../events";
|
||||
import * as log from "../../../log";
|
||||
import {LogCategory} from "../../../log";
|
||||
import {tr} from "../../../i18n/localize";
|
||||
import ReactDOM = require("react-dom");
|
||||
import {
|
||||
ConversationUIEvents
|
||||
} from "../../../ui/frames/side/ConversationDefinitions";
|
||||
import {ConversationUIEvents} from "../../../ui/frames/side/ConversationDefinitions";
|
||||
import {ConversationPanel} from "./AbstractConversationRenderer";
|
||||
import {AbstractConversationController} from "./AbstractConversationController";
|
||||
import {
|
||||
ChannelConversation, ChannelConversationEvents,
|
||||
ChannelConversation,
|
||||
ChannelConversationEvents,
|
||||
ChannelConversationManager,
|
||||
ChannelConversationManagerEvents
|
||||
} from "tc-shared/conversations/ChannelConversationManager";
|
||||
import {ServerFeature} from "tc-shared/connection/ServerFeatures";
|
||||
import ReactDOM = require("react-dom");
|
||||
|
||||
export class ChannelConversationController extends AbstractConversationController<
|
||||
ConversationUIEvents,
|
||||
|
@ -61,6 +61,18 @@ export class ChannelConversationController extends AbstractConversationControlle
|
|||
}));
|
||||
|
||||
this.uiEvents.register_handler(this, true);
|
||||
|
||||
this.listenerManager.push(connection.events().on("notify_connection_state_changed", event => {
|
||||
if(event.newState === ConnectionState.CONNECTED) {
|
||||
connection.serverFeatures.awaitFeatures().then(success => {
|
||||
if(!success) { return; }
|
||||
|
||||
this.setCrossChannelChatSupport(connection.serverFeatures.supportsFeature(ServerFeature.ADVANCED_CHANNEL_CHAT));
|
||||
});
|
||||
} else {
|
||||
this.setCrossChannelChatSupport(true);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
@ -84,8 +96,13 @@ export class ChannelConversationController extends AbstractConversationControlle
|
|||
|
||||
protected registerConversationEvents(conversation: ChannelConversation) {
|
||||
super.registerConversationEvents(conversation);
|
||||
|
||||
this.currentSelectedListener.push(conversation.events.on("notify_messages_deleted", event => {
|
||||
this.uiEvents.fire_react("notify_chat_message_delete", { messageIds: event.messages, chatId: conversation.getChatId() });
|
||||
}));
|
||||
|
||||
this.currentSelectedListener.push(conversation.events.on("notify_conversation_mode_changed", () => {
|
||||
this.reportStateToUI(conversation);
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -16,7 +16,8 @@ export type ChatEvent = { timestamp: number; uniqueId: string; } & (
|
|||
ChatEventQueryFailed |
|
||||
ChatEventPartnerInstanceChanged |
|
||||
ChatEventLocalAction |
|
||||
ChatEventPartnerAction
|
||||
ChatEventPartnerAction |
|
||||
ChatEventModeChanged
|
||||
);
|
||||
|
||||
export interface ChatEventUnreadTrigger {
|
||||
|
@ -64,6 +65,11 @@ export interface ChatEventPartnerAction {
|
|||
action: "disconnect" | "close" | "reconnect";
|
||||
}
|
||||
|
||||
export interface ChatEventModeChanged {
|
||||
type: "mode-changed";
|
||||
newMode: "normal" | "private" | "none"
|
||||
}
|
||||
|
||||
/* ---------- Chat States ---------- */
|
||||
export type ChatState = "normal" | "loading" | "no-permissions" | "error" | "unloaded";
|
||||
export type ChatHistoryState = "none" | "loading" | "available" | "error";
|
||||
|
|
|
@ -105,9 +105,9 @@ const BlockPing = () => {
|
|||
}
|
||||
|
||||
if(pingInfo.javaScript === undefined) {
|
||||
title = tr("Ping: " + pingInfo.native.toFixed(3) + "ms");
|
||||
title = tra("Ping: {}ms", pingInfo.native.toFixed(3));
|
||||
} else {
|
||||
title = tr("Native: " + pingInfo.native.toFixed(3) + "ms\nJavascript: " + pingInfo.javaScript.toFixed(3) + "ms");
|
||||
title = tra("Native: {}ms\nJavascript: {}ms", pingInfo.native.toFixed(3), pingInfo.javaScript.toFixed(3));
|
||||
}
|
||||
value = <div className={cssStyle.value + " " + cssStyle.ping + " " + pingClass} key={"ping"} title={title}>{pingInfo.native.toFixed(0)}ms</div>;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue