Some minor chat related bugfixing and separated the chat controller form the chat modal

master
WolverinDEV 2020-12-09 13:36:56 +01:00 committed by WolverinDEV
parent 3104dff238
commit fea0993c7d
44 changed files with 2178 additions and 1456 deletions

View File

@ -1,8 +1,14 @@
# Changelog:
* **09.12.20**
- Fixed the private messages unread indicator
- Properly updating the private message unread count
* **08.12.20**
- Fixed the permission editor not resolving unique ids
- Fixed client database info resolve
- Improved the side header bar
All values are not updating accordingly to the connection state
* **07.12.20**
- Fixed the Markdown to BBCode transpiler falsely emitting empty lines
- Fixed invalid BBCode escaping

View File

@ -38,214 +38,6 @@ html:root {
min-height: 200px;
.container-info {
user-select: none;
flex-grow: 0;
flex-shrink: 0;
height: 9em;
display: flex;
flex-direction: column;
justify-content: space-evenly;
background-color: var(--side-info-background);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
-moz-box-shadow: inset 0 0 5px var(--side-info-shadow);
-webkit-box-shadow: inset 0 0 5px var(--side-info-shadow);
box-shadow: inset 0 0 5px var(--side-info-shadow);
.lane {
padding-right: 10px;
padding-left: 10px;
display: flex;
flex-direction: row;
justify-content: stretch;
height: 3.25em;
.block, .button {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.block {
flex-shrink: 1;
flex-grow: 1;
min-width: 0;
&.right {
text-align: right;
&.mode-client_info {
max-width: calc(50% - #{$client_info_avatar_size / 2});
margin-left: calc(#{$client_info_avatar_size / 2});
}
}
&.left {
margin-right: .5em;
text-align: left;
padding-right: 10px;
&.mode-client_info {
max-width: calc(50% - #{$client_info_avatar_size / 2});
margin-right: calc(#{$client_info_avatar_size} / 2);
}
}
.title, .value, .small-value {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 0;
max-width: 100%;
}
.title {
display: block;
color: var(--side-info-title);
.container-indicator {
display: inline-flex;
flex-direction: column;
justify-content: space-around;
background: var(--side-info-indicator-background);
border: 1px solid var(--side-info-indicator-border);
border-radius: 4px;
text-align: center;
vertical-align: text-top;
color: var(--side-info-indicator);
font-size: .66em;
height: 1.3em;
min-width: .9em;
padding-right: 2px;
padding-left: 2px;
}
}
.value {
color: var(--side-info-value);
background-color: var(--side-info-value-background);
display: inline-block;
border-radius: .18em;
padding-right: .31em;
padding-left: .31em;
> div {
display: inline-block;
}
.icon-container, .icon {
vertical-align: middle;
margin-right: .25em;
}
&.value-ping {
//very-good good medium poor very-poor
&.very-good {
color: var(--side-info-ping-very-good);
}
&.good {
color: var(--side-info-ping-good);
}
&.medium {
color: var(--side-info-ping-medium);
}
&.poor {
color: var(--side-info-ping-poor);
}
&.very-poor {
color: var(--side-info-ping-very-poor);
}
}
&.chat-counter {
cursor: pointer;
}
&.bot-add-song {
color: var(--side-info-bot-add-song);
}
}
.small-value {
display: inline-block;
color: var(--side-info-value);
font-size: .66em;
vertical-align: top;
margin-top: -.2em;
}
.button {
color: var(--side-info-value);
background-color: var(--side-info-value-background);
display: inline-block;
&:not(.value) {
border-radius: .18em;
padding-right: .31em;
padding-left: .31em;
margin-top: 1.5em; /* because we've no title */
}
cursor: pointer;
&:hover {
background-color: #4e4e4e; /* TODO: Evaluate color */
}
@include transition(background-color $button_hover_animation_time ease-in-out);
}
}
&:not(.mode-channel_chat) {
.mode-channel_chat { display: none; }
}
&:not(.mode-private_chat) {
.mode-private_chat { display: none; }
}
&:not(.mode-client_info) {
.mode-client_info { display: none; }
}
&:not(.mode-music_bot) {
.mode-music_bot { display: none; }
}
&.mode-music_bot {
.mode-music_bot {
&.right {
margin-left: 8.5em;
}
&.left {
margin-right: 8.5em;
}
width: 60em; /* same width so flex-shrik applies equaly */
}
}
}
}
.container-chat {
width: 100%;

View File

@ -38,6 +38,9 @@ import {LocalClientEntry} from "./tree/Client";
import {ServerAddress} from "./tree/Server";
import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller";
import {global_client_actions} from "tc-shared/events/GlobalEvents";
import {ChannelConversationManager} from "./conversations/ChannelConversationManager";
import {PrivateConversationManager} from "tc-shared/conversations/PrivateConversationManager";
import {ChannelConversationController} from "./ui/frames/side/ChannelConversationController";
export enum InputHardwareState {
MISSING,
@ -154,6 +157,9 @@ export class ConnectionHandler {
serverFeatures: ServerFeatures;
private channelConversations: ChannelConversationManager;
private privateConversations: PrivateConversationManager;
private _clientId: number = 0;
private localClient: LocalClientEntry;
@ -205,6 +211,9 @@ export class ConnectionHandler {
this.fileManager = new FileManager(this);
this.permissions = new PermissionManager(this);
this.privateConversations = new PrivateConversationManager(this);
this.channelConversations = new ChannelConversationManager(this);
this.pluginCmdRegistry = new PluginCmdRegistry(this);
this.video_frame = new ChannelVideoFrame(this);
@ -217,9 +226,9 @@ export class ConnectionHandler {
this.localClient.channelTree = this.channelTree;
this.event_registry.register_handler(this);
this.events().fire("notify_handler_initialized");
this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler());
this.events().fire("notify_handler_initialized");
}
initialize_client_state(source?: ConnectionHandler) {
@ -341,6 +350,14 @@ export class ConnectionHandler {
getClient() : LocalClientEntry { return this.localClient; }
getClientId() { return this._clientId; }
getPrivateConversations() : PrivateConversationManager {
return this.privateConversations;
}
getChannelConversations() : ChannelConversationManager {
return this.channelConversations;
}
initializeLocalClient(clientId: number, acceptedName: string) {
this._clientId = clientId;
this.localClient["_clientId"] = clientId;
@ -354,8 +371,8 @@ export class ConnectionHandler {
@EventHandler<ConnectionEvents>("notify_connection_state_changed")
private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) {
this.connection_state = event.new_state;
if(event.new_state === ConnectionState.CONNECTED) {
this.connection_state = event.newState;
if(event.newState === ConnectionState.CONNECTED) {
log.info(LogCategory.CLIENT, tr("Client connected"));
this.log.log(EventType.CONNECTION_CONNECTED, {
serverAddress: {
@ -679,8 +696,8 @@ export class ConnectionHandler {
private on_connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) {
console.log("From %s to %s", ConnectionState[old_state], ConnectionState[new_state]);
this.event_registry.fire("notify_connection_state_changed", {
old_state: old_state,
new_state: new_state
oldState: old_state,
newState: new_state
});
}
@ -1016,6 +1033,12 @@ export class ConnectionHandler {
}
this.localClient = undefined;
this.privateConversations?.destroy();
this.privateConversations = undefined;
this.channelConversations?.destroy();
this.channelConversations = undefined;
this.channelTree?.destroy();
this.channelTree = undefined;
@ -1194,8 +1217,8 @@ export interface ConnectionEvents {
}
notify_connection_state_changed: {
old_state: ConnectionState,
new_state: ConnectionState
oldState: ConnectionState,
newState: ConnectionState
},
/* the handler has become visible/invisible for the client */

View File

@ -17,7 +17,7 @@ import {formatMessage} from "../ui/frames/chat";
import {spawnPoke} from "../ui/modal/ModalPoke";
import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "../connection/AbstractCommandHandler";
import {batch_updates, BatchUpdateType, flush_batched_updates} from "../ui/react-elements/ReactComponentBase";
import {OutOfViewClient} from "../ui/frames/side/PrivateConversationManager";
import {OutOfViewClient} from "../ui/frames/side/PrivateConversationController";
import {renderBBCodeAsJQuery} from "../text/bbcode";
import {tr} from "../i18n/localize";
import {EventClient, EventType} from "../ui/frames/log/Definitions";
@ -74,9 +74,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this["notifychannelsubscribed"] = this.handleNotifyChannelSubscribed;
this["notifychannelunsubscribed"] = this.handleNotifyChannelUnsubscribed;
//this["notifyconversationhistory"] = this.handleNotifyConversationHistory;
//this["notifyconversationmessagedelete"] = this.handleNotifyConversationMessageDelete;
this["notifymusicstatusupdate"] = this.handleNotifyMusicStatusUpdate;
this["notifymusicplayersongchange"] = this.handleMusicPlayerSongChange;
@ -413,7 +410,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handleCommandChannelDelete(json) {
let tree = this.connection.client.channelTree;
const conversations = this.connection.client.side_bar.channel_conversations();
const conversations = this.connection.client.getChannelConversations();
let playSound = false;
@ -448,7 +445,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handleCommandChannelHide(json) {
let tree = this.connection.client.channelTree;
const conversations = this.connection.client.side_bar.channel_conversations();
const conversations = this.connection.client.getChannelConversations();
log.info(LogCategory.NETWORKING, tr("Got %d channel hides"), json.length);
for(let index = 0; index < json.length; index++) {
@ -556,9 +553,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
if(client instanceof LocalClientEntry) {
client.initializeListener();
this.connection_handler.update_voice_status();
this.connection_handler.side_bar.info_frame().update_channel_talk();
const conversations = this.connection.client.side_bar.channel_conversations();
conversations.setSelectedConversation(client.currentChannel().channelId);
const conversations = this.connection.client.getChannelConversations();
conversations.setSelectedConversation(conversations.findOrCreateConversation(client.currentChannel().channelId));
}
}
}
@ -586,7 +582,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
} else {
this.connection.client.handleDisconnect(DisconnectReason.UNKNOWN, entry);
}
this.connection_handler.side_bar.info_frame().update_channel_talk();
return;
}
@ -673,9 +668,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
for(const entry of client.channelTree.clientsByChannel(channelFrom)) {
entry.getVoiceClient()?.abortReplay();
}
const side_bar = this.connection_handler.side_bar;
side_bar.info_frame().update_channel_talk();
} else {
client.speaking = false;
}
@ -810,8 +802,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
uniqueId: targetIsOwn ? json["invokeruid"] : undefined
} as OutOfViewClient;
const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.findOrCreateConversation(chatPartner);
const conversationManager = this.connection_handler.getPrivateConversations();
const conversation = conversationManager.findOrCreateConversation(chatPartner);
conversation.handleIncomingMessage(chatPartner, !targetIsOwn, {
sender_database_id: targetClientEntry ? targetClientEntry.properties.client_database_id : 0,
@ -842,7 +834,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
});
}
this.connection_handler.side_bar.info_frame().update_chat_counter();
} else if(mode == 2) {
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
const own_channel_id = this.connection.client.getClient().currentChannel().channelId;
@ -854,7 +845,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5});
}
const conversations = this.connection_handler.side_bar.channel_conversations();
const conversations = this.connection_handler.getChannelConversations();
conversations.findOrCreateConversation(channel_id).handleIncomingMessage({
sender_database_id: invoker ? invoker.properties.client_database_id : 0,
sender_name: json["invokername"],
@ -865,7 +856,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}, invoker instanceof LocalClientEntry);
} else if(mode == 3) {
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
const conversations = this.connection_handler.side_bar.channel_conversations();
const conversations = this.connection_handler.getChannelConversations();
this.connection_handler.log.log(EventType.GLOBAL_MESSAGE, {
isOwnMessage: invoker instanceof LocalClientEntry,
@ -891,7 +882,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
notifyClientChatComposing(json) {
json = json[0];
const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation_manager = this.connection_handler.getPrivateConversations();
const conversation = conversation_manager.findConversation(json["cluid"]);
conversation?.handleRemoteComposing(parseInt(json["clid"]));
}
@ -899,8 +890,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handleNotifyClientChatClosed(json) {
json = json[0]; //Only one bulk
const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.findConversation(json["cluid"]);
const conversationManager = this.connection_handler.getPrivateConversations();
const conversation = conversationManager.findConversation(json["cluid"]);
if(!conversation) {
log.warn(LogCategory.GENERAL, tr("Received chat close for client, but we haven't a chat open."));
return;
@ -1054,22 +1045,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
}
/*
handleNotifyConversationMessageDelete(json: any[]) {
let conversation: Conversation;
const conversations = this.connection.client.side_bar.channel_conversations();
for(const entry of json) {
if(typeof(entry["cid"]) !== "undefined")
conversation = conversations.conversation(parseInt(entry["cid"]), false);
if(!conversation)
continue;
conversation.delete_messages(parseInt(entry["timestamp_begin"]), parseInt(entry["timestamp_end"]), parseInt(entry["cldbid"]), parseInt(entry["limit"]));
}
}
*/
handleNotifyMusicStatusUpdate(json: any[]) {
json = json[0];

View File

@ -20,10 +20,18 @@ export const CommandOptionDefaults: CommandOptions = {
timeout: 10_000
};
export type ConnectionPing = {
javascript: number | undefined,
native: number
};
export interface ServerConnectionEvents {
notify_connection_state_changed: {
oldState: ConnectionState,
newState: ConnectionState
},
notify_ping_updated: {
newPing: ConnectionPing
}
}
@ -73,10 +81,7 @@ export abstract class AbstractServerConnection {
getConnectionState() { return this.connectionState; }
abstract ping() : {
native: number,
javascript?: number
};
abstract ping() : ConnectionPing;
}
export class ServerCommand {

View File

@ -91,8 +91,9 @@ export class PluginCmdRegistry {
}
registerHandler(handler: PluginCmdHandler) {
if(this.handlerMap[handler.getChannel()] !== undefined)
if(this.handlerMap[handler.getChannel()] !== undefined) {
throw tra("A handler for channel {} already exists", handler.getChannel());
}
this.handlerMap[handler.getChannel()] = handler;
handler["currentServerConnection"] = this.connection.serverConnection;
@ -100,8 +101,9 @@ export class PluginCmdRegistry {
}
unregisterHandler(handler: PluginCmdHandler) {
if(this.handlerMap[handler.getChannel()] !== handler)
if(this.handlerMap[handler.getChannel()] !== handler) {
return;
}
handler["currentServerConnection"] = undefined;
handler.handleHandlerUnregistered();

View File

@ -68,7 +68,7 @@ export class ServerFeatures {
});
this.connection.events().on("notify_connection_state_changed", this.stateChangeListener = event => {
if(event.new_state === ConnectionState.CONNECTED) {
if(event.newState === ConnectionState.CONNECTED) {
this.connection.getServerConnection().send_command("listfeaturesupport").catch(error => {
this.disableAllFeatures();
if(error instanceof CommandResult) {
@ -84,7 +84,7 @@ export class ServerFeatures {
this.featureAwaitCallback(true);
}
});
} else if(event.new_state === ConnectionState.DISCONNECTING || event.new_state === ConnectionState.UNCONNECTED) {
} else if(event.newState === ConnectionState.DISCONNECTING || event.newState === ConnectionState.UNCONNECTED) {
this.disableAllFeatures();
this.featureAwait = undefined;
this.featureAwaitCallback = undefined;

View File

@ -0,0 +1,383 @@
import {
ChatEvent,
ChatEventMessage,
ChatMessage,
ChatState, ConversationHistoryResponse
} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {Registry} from "tc-shared/events";
import {ConnectionHandler, ConnectionState} 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";
export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */
export interface AbstractChatEvents {
notify_chat_event: {
triggerUnread: boolean,
event: ChatEvent
},
notify_unread_timestamp_changed: {
timestamp: number
},
notify_unread_state_changed: {
unread: boolean
},
notify_send_toggle: {
enabled: boolean
},
notify_state_changed: {
oldSTate: ChatState,
newState: ChatState
},
notify_history_state_changed: {
hasHistory: boolean
}
}
export abstract class AbstractChat<Events extends AbstractChatEvents> {
readonly events: Registry<Events>;
protected readonly connection: ConnectionHandler;
protected readonly chatId: string;
protected presentMessages: ChatEvent[] = [];
protected presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
private mode: ChatState = "unloaded";
protected failedPermission: string;
protected errorMessage: string;
protected conversationPrivate: boolean = false;
protected crossChannelChatSupported: boolean = true;
protected unreadTimestamp: number;
protected unreadState: boolean = false;
protected messageSendEnabled: boolean = true;
private history = false;
protected constructor(connection: ConnectionHandler, chatId: string) {
this.events = new Registry<Events>();
this.connection = connection;
this.chatId = chatId;
this.unreadTimestamp = Date.now();
}
destroy() {
this.events.destroy();
}
public getCurrentMode() : ChatState { return this.mode; };
protected setCurrentMode(mode: ChatState) {
if(this.mode === mode) {
return;
}
const oldState = this.mode;
this.mode = mode;
this.events.fire("notify_state_changed", { oldSTate: oldState, newState: mode });
}
public registerChatEvent(event: ChatEvent, triggerUnread: boolean) {
if(event.type === "message") {
let index = 0;
while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= event.timestamp) {
index++;
}
this.presentMessages.splice(index, 0, event);
const deleteMessageCount = Math.max(0, this.presentMessages.length - kMaxChatFrameMessageSize);
this.presentMessages.splice(0, deleteMessageCount);
if(deleteMessageCount > 0) {
this.setHistory(true);
}
index -= deleteMessageCount;
if(event.isOwnMessage) {
this.setUnreadTimestamp(Date.now());
} else if(!this.isUnread() && triggerUnread) {
this.setUnreadTimestamp(event.message.timestamp - 1);
} else {
/* mark the last message as read */
this.setUnreadTimestamp(event.message.timestamp);
}
this.events.fire("notify_chat_event", {
triggerUnread: triggerUnread,
event: event
});
} else {
this.presentEvents.push(event);
this.presentEvents.sort((a, b) => a.timestamp - b.timestamp);
/* TODO: Cutoff too old events! */
if(!this.isUnread() && triggerUnread) {
this.setUnreadTimestamp(event.timestamp - 1);
} else {
/* mark the last message as read */
this.setUnreadTimestamp(event.timestamp);
}
this.events.fire("notify_chat_event", {
triggerUnread: triggerUnread,
event: event
});
}
}
protected registerIncomingMessage(message: ChatMessage, isOwnMessage: boolean, uniqueId: string) {
this.registerChatEvent({
type: "message",
isOwnMessage: isOwnMessage,
uniqueId: uniqueId,
timestamp: message.timestamp,
message: message
}, !isOwnMessage);
}
protected doSendMessage(message: string, targetMode: number, target: number) : Promise<boolean> {
let msg = preprocessChatMessageForSend(message);
return this.connection.serverConnection.send_command("sendtextmessage", {
targetmode: targetMode,
cid: target,
target: target,
msg: msg
}, { process_result: false }).then(async () => true).catch(error => {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "permission",
failedPermission: this.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknown")
}, false);
} else {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: error.formattedMessage()
}, false);
}
} else if(typeof error === "string") {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: error
}, false);
} else {
logWarn(LogCategory.CHAT, tr("Failed to send channel chat message to %s: %o"), this.chatId, error);
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("lookup the console")
}, false);
}
return false;
});
}
public getChatId() : string {
return this.chatId;
}
public isUnread() : boolean {
return this.unreadState;
}
public isPrivate() : boolean {
return this.conversationPrivate;
}
public isSendEnabled() : boolean {
return this.messageSendEnabled;
}
public getUnreadTimestamp() : number | undefined {
return this.unreadTimestamp;
}
public getPresentMessages() : ChatEvent[] {
return this.presentMessages;
}
public getPresentEvents() : ChatEvent[] {
return this.presentEvents;
}
public getErrorMessage() : string | undefined {
return this.errorMessage;
}
public getFailedPermission() : string | undefined {
return this.failedPermission;
}
public setUnreadTimestamp(timestamp: number) {
if(this.unreadTimestamp !== timestamp) {
this.unreadTimestamp = timestamp;
this.events.fire("notify_unread_timestamp_changed", { timestamp: timestamp });
}
/* do update the unread state anyways since setUnreadTimestamp will be called when new messages arrive */
this.updateUnreadState();
}
protected updateUnreadState() {
const newState = this.unreadTimestamp < this.lastEvent()?.timestamp;
if(this.unreadState !== newState) {
this.unreadState = newState;
this.events.fire("notify_unread_state_changed", { unread: newState });
}
}
public hasHistory() : boolean {
return this.history;
}
protected setHistory(hasHistory: boolean) {
if(this.history === hasHistory) {
return;
}
this.history = hasHistory;
this.events.fire("notify_history_state_changed", { hasHistory: hasHistory });
}
protected lastEvent() : ChatEvent | undefined {
if(this.presentMessages.length === 0) {
return this.presentEvents.last();
} else if(this.presentEvents.length === 0 || this.presentMessages.last().timestamp > this.presentEvents.last().timestamp) {
return this.presentMessages.last();
} else {
return this.presentEvents.last();
}
}
protected sendMessageSendingEnabled(enabled: boolean) {
if(this.messageSendEnabled === enabled) {
return;
}
this.messageSendEnabled = enabled;
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);
}
export interface AbstractChatManagerEvents<ConversationType> {
notify_selected_changed: {
oldConversation: ConversationType,
newConversation: ConversationType
},
notify_conversation_destroyed: {
conversation: ConversationType
},
notify_conversation_created: {
conversation: ConversationType
},
notify_unread_count_changed: {
unreadConversations: number
}
}
export abstract class AbstractChatManager<ManagerEvents extends AbstractChatManagerEvents<ConversationType>, ConversationType extends AbstractChat<ConversationEvents>, ConversationEvents extends AbstractChatEvents> {
readonly events: Registry<ManagerEvents>;
readonly connection: ConnectionHandler;
protected readonly listenerConnection: (() => void)[];
private readonly listenerUnreadTimestamp: () => void;
private conversations: {[key: string]: ConversationType} = {};
private selectedConversation: ConversationType;
private currentUnreadCount: number;
protected constructor(connection: ConnectionHandler) {
this.events = new Registry<ManagerEvents>();
this.listenerConnection = [];
this.currentUnreadCount = 0;
this.listenerUnreadTimestamp = () => {
let count = this.getConversations().filter(conversation => conversation.isUnread()).length;
if(count === this.currentUnreadCount) { return; }
this.currentUnreadCount = count;
this.events.fire("notify_unread_count_changed", { unreadConversations: count });
}
}
destroy() {
this.events.destroy();
this.listenerConnection.forEach(callback => callback());
this.listenerConnection.splice(0, this.listenerConnection.length);
}
getConversations() : ConversationType[] {
return Object.values(this.conversations);
}
getUnreadCount() : number {
return this.currentUnreadCount;
}
getSelectedConversation() : ConversationType {
return this.selectedConversation;
}
setSelectedConversation(conversation: ConversationType | undefined) {
if(this.selectedConversation === conversation) {
return;
}
const oldConversation = this.selectedConversation;
this.selectedConversation = conversation;
this.events.fire("notify_selected_changed", {
oldConversation: oldConversation,
newConversation: conversation
});
}
findConversationById(id: string) : ConversationType {
return this.conversations[id];
}
protected registerConversation(conversation: ConversationType) {
conversation.events.on("notify_unread_state_changed", this.listenerUnreadTimestamp);
this.conversations[conversation.getChatId()] = conversation;
this.events.fire("notify_conversation_created", { conversation: conversation });
this.listenerUnreadTimestamp();
}
protected unregisterConversation(conversation: ConversationType) {
conversation = this.conversations[conversation.getChatId()];
if(!conversation) { return; }
if(conversation === this.selectedConversation) {
this.setSelectedConversation(undefined);
}
conversation.events.off("notify_unread_state_changed", this.listenerUnreadTimestamp);
delete this.conversations[conversation.getChatId()];
this.events.fire("notify_conversation_destroyed", { conversation: conversation });
this.listenerUnreadTimestamp();
}
}

View File

@ -1,44 +1,58 @@
import * as React from "react";
import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler";
import {EventHandler, Registry} from "../../../events";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {CommandResult} from "../../../connection/ServerConnectionDeclaration";
import {ServerCommand} from "../../../connection/ConnectionBase";
import {Settings} from "../../../settings";
import {traj, tr} from "../../../i18n/localize";
import {createErrorModal} from "../../../ui/elements/Modal";
import ReactDOM = require("react-dom");
import {
ChatMessage, ConversationHistoryResponse,
ConversationUIEvents
} from "../../../ui/frames/side/ConversationDefinitions";
import {ConversationPanel} from "../../../ui/frames/side/ConversationUI";
import {AbstractChat, AbstractChatManager, kMaxChatFrameMessageSize} from "./AbstractConversion";
import {ErrorCode} from "../../../connection/ErrorCode";
AbstractChat,
AbstractChatEvents,
AbstractChatManager,
AbstractChatManagerEvents,
kMaxChatFrameMessageSize
} from "./AbstractConversion";
import {ChatMessage, ConversationHistoryResponse} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {Settings} from "tc-shared/settings";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
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";
export interface ChannelConversationEvents extends AbstractChatEvents {
notify_messages_deleted: { messages: string[] },
notify_messages_loaded: {}
}
const kSuccessQueryThrottle = 5 * 1000;
const kErrorQueryThrottle = 30 * 1000;
export class Conversation extends AbstractChat<ConversationUIEvents> {
private readonly handle: ConversationManager;
export class ChannelConversation extends AbstractChat<ChannelConversationEvents> {
private readonly handle: ChannelConversationManager;
public readonly conversationId: number;
private conversationVolatile: boolean = false;
private preventUnreadUpdate = false;
private executingHistoryQueries = false;
private pendingHistoryQueries: (() => Promise<any>)[] = [];
public historyQueryResponse: ChatMessage[] = [];
constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) {
super(handle.connection, id.toString(), events);
constructor(handle: ChannelConversationManager, id: number) {
super(handle.connection, id.toString());
this.handle = handle;
this.conversationId = id;
this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
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);
});
}
destroy() { }
destroy() {
super.destroy();
}
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
return new Promise<ConversationHistoryResponse>(resolve => {
@ -108,7 +122,7 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
errorMessage = error.formattedMessage();
}
} else {
log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
logError(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
errorMessage = tr("lookup the console");
}
resolve({
@ -125,56 +139,54 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
queryCurrentMessages() {
this.presentMessages = [];
this.mode = "loading";
this.setCurrentMode("loading");
this.reportStateToUI();
this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => {
this.conversationPrivate = false;
this.conversationVolatile = false;
this.failedPermission = undefined;
this.errorMessage = undefined;
this.hasHistory = !!history.moreEvents;
this.setHistory(!!history.moreEvents);
this.presentMessages = history.events?.map(e => Object.assign({ uniqueId: "m-" + this.conversationId + "-" + e.timestamp }, e)) || [];
switch (history.status) {
case "error":
this.mode = "normal";
this.presentEvents.push({
this.setCurrentMode("normal");
this.registerChatEvent({
type: "query-failed",
timestamp: Date.now(),
uniqueId: "qf-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
message: history.errorMessage
});
}, false);
break;
case "no-permission":
this.mode = "no-permissions";
this.setCurrentMode("no-permissions");
this.failedPermission = history.failedPermission;
break;
case "private":
this.conversationPrivate = true;
this.mode = "normal";
this.setCurrentMode("normal");
break;
case "success":
this.mode = "normal";
this.setCurrentMode("normal");
break;
case "unsupported":
this.crossChannelChatSupported = false;
this.conversationPrivate = true;
this.mode = "normal";
this.setCurrentMode("normal");
break;
}
/* only update the UI if needed */
if(this.handle.selectedConversation() === this.conversationId)
this.reportStateToUI();
this.events.fire("notify_messages_loaded");
});
}
protected canClientAccessChat() {
/* TODO: Query this state and if changed notify state */
public canClientAccessChat() {
return this.conversationId === 0 || this.handle.connection.getClient().currentChannel()?.channelId === this.conversationId;
}
@ -186,7 +198,7 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
try {
const promise = this.pendingHistoryQueries.pop_front()();
promise
.catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error))
.catch(error => logError(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error))
.then(() => { this.executingHistoryQueries = false; this.executeHistoryQuery(); });
} catch (e) {
this.executingHistoryQueries = false;
@ -205,8 +217,12 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
return;
}
if(timestamp > this.lastReadMessage) {
this.setUnreadTimestamp(this.lastReadMessage);
if(this.unreadTimestamp < timestamp) {
this.registerChatEvent({
type: "unread-trigger",
timestamp: timestamp,
uniqueId: "unread-trigger-" + Date.now() + " - " + timestamp
}, true);
}
}
@ -217,29 +233,36 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) {
let limit = { current: criteria.limit };
this.presentMessages = this.presentMessages.filter(message => {
if(message.type !== "message")
return;
const deletedMessages = this.presentMessages.filter(message => {
if(message.type !== "message") {
return false;
}
if(message.message.sender_database_id !== criteria.cldbid)
return true;
if(message.message.sender_database_id !== criteria.cldbid) {
return false;
}
if(criteria.end != 0 && message.timestamp > criteria.end)
return true;
if(criteria.end != 0 && message.timestamp > criteria.end) {
return false;
}
if(criteria.begin != 0 && message.timestamp < criteria.begin)
return true;
if(criteria.begin != 0 && message.timestamp < criteria.begin) {
return false;
}
return --limit.current < 0;
/* if the limit is zero it means all messages */
return --limit.current >= 0;
});
this.events.fire("notify_chat_message_delete", { chatId: this.conversationId.toString(), criteria: criteria });
this.presentMessages = this.presentMessages.filter(message => deletedMessages.indexOf(message) === -1);
this.events.fire("notify_messages_deleted", { messages: deletedMessages.map(message => message.uniqueId) });
this.updateUnreadState();
}
public deleteMessage(messageUniqueId: string) {
const message = this.presentMessages.find(e => e.uniqueId === messageUniqueId);
if(!message) {
log.warn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId);
logWarn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId);
return;
}
@ -253,32 +276,34 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
limit: 1,
cldbid: message.message.sender_database_id
}, { process_result: false }).catch(error => {
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
if(error instanceof CommandResult)
logError(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
if(error instanceof CommandResult) {
error = error.extra_message || error.message;
}
createErrorModal(tr("Failed to delete message"), traj("Failed to delete conversation message{:br:}Error: {}", error)).open();
});
}
setUnreadTimestamp(timestamp: number | undefined) {
setUnreadTimestamp(timestamp: number) {
super.setUnreadTimestamp(timestamp);
/* we've to update the last read timestamp regardless of if we're having actual unread stuff */
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(timestamp !== undefined);
if(this.preventUnreadUpdate) {
return;
}
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp);
}
public localClientSwitchedChannel(type: "join" | "leave") {
this.presentEvents.push({
this.registerChatEvent({
type: "local-user-switch",
uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
timestamp: Date.now(),
mode: type
});
}, false);
if(this.conversationId === this.handle.selectedConversation())
this.reportStateToUI();
/* TODO: Update can access state! */
}
sendMessage(text: string) {
@ -286,138 +311,84 @@ export class Conversation extends AbstractChat<ConversationUIEvents> {
}
}
export class ConversationManager extends AbstractChatManager<ConversationUIEvents> {
readonly connection: ConnectionHandler;
readonly htmlTag: HTMLDivElement;
export interface ChannelConversationManagerEvents extends AbstractChatManagerEvents<ChannelConversation> { }
private conversations: {[key: number]: Conversation} = {};
private selectedConversation_: number;
export class ChannelConversationManager extends AbstractChatManager<ChannelConversationManagerEvents, ChannelConversation, ChannelConversationEvents> {
readonly connection: ConnectionHandler;
constructor(connection: ConnectionHandler) {
super();
super(connection);
this.connection = connection;
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "column";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
ReactDOM.render(React.createElement(ConversationPanel, {
events: this.uiEvents,
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}), this.htmlTag);
/*
spawnExternalModal("conversation", this.uiEvents, {
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}).open().then(() => {
console.error("Opened");
});
*/
this.uiEvents.on("action_select_chat", event => this.selectedConversation_ = parseInt(event.chatId));
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
this.conversations = {};
this.setSelectedConversation(-1);
}
}));
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible)
return;
this.handlePanelShow();
}));
connection.events().one("notify_handler_initialized", () => this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_client_moved", event => {
connection.events().one("notify_handler_initialized", () => this.listenerConnection.push(connection.channelTree.events.on("notify_client_moved", event => {
if(event.client instanceof LocalClientEntry) {
event.oldChannel && this.findOrCreateConversation(event.oldChannel.channelId).localClientSwitchedChannel("leave");
this.findOrCreateConversation(event.newChannel.channelId).localClientSwitchedChannel("join");
}
})));
this.uiEvents.register_handler(this, true);
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
this.listenerConnection.push(connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.oldState) !== ConnectionState.socketConnected(event.newState)) {
this.setSelectedConversation(undefined);
this.getConversations().forEach(conversation => {
this.unregisterConversation(conversation);
conversation.destroy();
});
}
}));
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_list_received", () => {
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)));
this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_list_received", () => {
this.queryUnreadFlags();
}));
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_updated", _event => {
this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_updated", () => {
/* TODO private flag! */
}));
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
this.uiEvents.unregister_handler(this);
this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy();
super.destroy();
}
selectedConversation() {
return this.selectedConversation_;
findConversation(channelId: number) : ChannelConversation {
return this.findConversationById(channelId.toString());
}
setSelectedConversation(id: number) {
if(id >= 0)
this.findOrCreateConversation(id);
this.uiEvents.fire("notify_selected_chat", { chatId: id.toString() });
}
@EventHandler<ConversationUIEvents>("action_select_chat")
private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) {
this.setSelectedConversation(parseInt(event.chatId));
}
findConversation(id: number) : Conversation {
for(const conversation of Object.values(this.conversations))
if(conversation.conversationId === id)
return conversation;
return undefined;
}
protected findChat(id: string): AbstractChat<ConversationUIEvents> {
return this.findConversation(parseInt(id));
}
findOrCreateConversation(id: number) {
let conversation = this.findConversation(id);
findOrCreateConversation(channelId: number) {
let conversation = this.findConversation(channelId);
if(!conversation) {
conversation = new Conversation(this, this.uiEvents, id);
this.conversations[id] = conversation;
conversation = new ChannelConversation(this, channelId);
this.registerConversation(conversation);
}
return conversation;
}
destroyConversation(id: number) {
delete this.conversations[id];
const conversation = this.findConversation(id);
if(!conversation) {
return;
}
if(id === this.selectedConversation_)
this.uiEvents.fire("action_select_chat", { chatId: "unselected" });
this.unregisterConversation(conversation);
conversation.destroy();
}
queryUnreadFlags() {
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }});
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
});
}
private handleConversationHistory(command: ServerCommand) {
const conversation = this.findConversation(parseInt(command.arguments[0]["cid"]));
if(!conversation) {
log.warn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]);
logWarn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]);
return;
}
@ -444,8 +415,9 @@ export class ConversationManager extends AbstractChatManager<ConversationUIEvent
private handleConversationMessageDelete(command: ServerCommand) {
const data = command.arguments[0];
const conversation = this.findConversation(parseInt(data["cid"]));
if(!conversation)
if(!conversation) {
return;
}
conversation.handleDeleteMessages({
limit: parseInt(data["limit"]),
@ -454,30 +426,4 @@ export class ConversationManager extends AbstractChatManager<ConversationUIEvent
cldbid: parseInt(data["cldbid"])
})
}
@EventHandler<ConversationUIEvents>("action_delete_message")
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
const conversation = this.findConversation(parseInt(event.chatId));
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId);
return;
}
conversation.deleteMessage(event.uniqueId);
}
@EventHandler<ConversationUIEvents>("query_selected_chat")
private handleQuerySelectedChat() {
this.uiEvents.fire_react("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""})
}
@EventHandler<ConversationUIEvents>("notify_selected_chat")
private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) {
this.selectedConversation_ = parseInt(event.chatId);
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
protected handleClearUnreadFlag1(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.connection.channelTree.findChannel(parseInt(event.chatId))?.setUnread(false);
}
}

View File

@ -1,9 +1,8 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {ChatEvent} from "../../../ui/frames/side/ConversationDefinitions";
import { tr } from "tc-shared/i18n/localize";
import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log";
import {ChatEvent} from "tc-shared/ui/frames/side/ConversationDefinitions";
const clientUniqueId2StoreName = uniqueId => "conversation-" + uniqueId;
@ -65,17 +64,18 @@ function fireDatabaseStateChanged() {
try {
databaseStateChangedCallbacks.pop()();
} catch (error) {
log.error(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error);
logError(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error);
}
}
}
let cacheImportUniqueKeyId = 0;
async function importChatsFromCacheStorage(database: IDBDatabase) {
if(!(await caches.has("chat_history")))
if(!(await caches.has("chat_history"))) {
return;
}
log.info(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments."));
logInfo(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments."));
let chatEvents = {};
const cache = await caches.open("chat_history");
@ -83,7 +83,7 @@ async function importChatsFromCacheStorage(database: IDBDatabase) {
for(const chat of await cache.keys()) {
try {
if(!chat.url.startsWith("https://_local_cache/cache_request_")) {
log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url);
logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url);
continue;
}
@ -91,8 +91,9 @@ async function importChatsFromCacheStorage(database: IDBDatabase) {
const events: ChatEvent[] = chatEvents[clientUniqueId] || (chatEvents[clientUniqueId] = []);
const data = await (await cache.match(chat)).json();
if(!Array.isArray(data))
if(!Array.isArray(data)) {
throw tr("array expected");
}
for(const event of data) {
events.push({
@ -110,18 +111,20 @@ async function importChatsFromCacheStorage(database: IDBDatabase) {
});
}
} catch (error) {
log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error);
logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error);
}
}
const clientUniqueIds = Object.keys(chatEvents);
if(clientUniqueIds.length === 0)
if(clientUniqueIds.length === 0) {
return;
}
log.info(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length);
logInfo(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length);
await requestDatabaseUpdate(database => {
for(const uniqueId of clientUniqueIds)
for(const uniqueId of clientUniqueIds) {
doInitializeUser(uniqueId, database);
}
});
await requestDatabase();
@ -134,7 +137,8 @@ async function importChatsFromCacheStorage(database: IDBDatabase) {
});
await new Promise(resolve => store.transaction.oncomplete = resolve);
}
log.info(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data."));
logInfo(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data."));
await caches.delete("chat_history");
}
@ -153,7 +157,7 @@ async function doOpenDatabase(forceUpgrade: boolean) {
if(event.oldVersion === 0) {
/* database newly created */
importChatsFromCacheStorage(openRequest.result).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error);
logWarn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error);
});
}
upgradePerformed = true;
@ -161,7 +165,7 @@ async function doOpenDatabase(forceUpgrade: boolean) {
try {
databaseUpdateRequests.pop()(openRequest.result);
} catch (error) {
log.error(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error);
logError(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error);
}
}
};
@ -181,14 +185,14 @@ async function doOpenDatabase(forceUpgrade: boolean) {
localStorage.setItem("indexeddb-private-conversations-version", database.version.toString());
if(!upgradePerformed && forceUpgrade) {
log.warn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again."));
logWarn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again."));
database.close();
await new Promise(resolve => database.onclose = resolve);
continue;
}
database.onversionchange = () => {
log.debug(LogCategory.CHAT, tr("Received external database version change. Closing database."));
logDebug(LogCategory.CHAT, tr("Received external database version change. Closing database."));
databaseMode = "closed";
executeClose();
};
@ -233,10 +237,10 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
try {
await doOpenDatabase(false);
log.debug(LogCategory.CHAT, tr("Successfully initialized private conversation history database"));
logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database"));
} catch (error) {
log.error(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error);
log.error(LogCategory.CHAT, tr("Do not saving the private conversation chat."));
logError(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error);
logError(LogCategory.CHAT, tr("Do not saving the private conversation chat."));
}
}
});

View File

@ -1,25 +1,15 @@
import {ClientEntry} from "../../../tree/Client";
import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler";
import {EventHandler, Registry} from "../../../events";
import {
PrivateConversationInfo,
PrivateConversationUIEvents
} from "../../../ui/frames/side/PrivateConversationDefinitions";
import * as ReactDOM from "react-dom";
import * as React from "react";
import {PrivateConversationsPanel} from "../../../ui/frames/side/PrivateConversationUI";
import {
ChatEvent,
ChatMessage,
ConversationHistoryResponse,
ConversationUIEvents
} from "../../../ui/frames/side/ConversationDefinitions";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {queryConversationEvents, registerConversationEvent} from "../../../ui/frames/side/PrivateConversationHistory";
import {AbstractChat, AbstractChatManager} from "../../../ui/frames/side/AbstractConversion";
AbstractChat,
AbstractChatEvents,
AbstractChatManager,
AbstractChatManagerEvents
} from "tc-shared/conversations/AbstractConversion";
import {ClientEntry} from "tc-shared/tree/Client";
import {ChatEvent, ChatMessage, ConversationHistoryResponse} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {ChannelTreeEvents} from "tc-shared/tree/ChannelTree";
import { tr } from "tc-shared/i18n/localize";
import {queryConversationEvents, registerConversationEvent} from "tc-shared/conversations/PrivateConversationHistory";
import {LogCategory, logWarn} from "tc-shared/log";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
export type OutOfViewClient = {
nickname: string,
@ -29,16 +19,29 @@ export type OutOfViewClient = {
let receivingEventUniqueIdIndex = 0;
export class PrivateConversation extends AbstractChat<PrivateConversationUIEvents> {
export interface PrivateConversationEvents extends AbstractChatEvents {
notify_partner_typing: {},
notify_partner_changed: {
chatId: string,
clientId: number,
name: string
},
notify_partner_name_changed: {
chatId: string,
name: string
}
}
export class PrivateConversation extends AbstractChat<PrivateConversationEvents> {
public readonly clientUniqueId: string;
private activeClientListener: (() => void)[] | undefined = undefined;
private activeClient: ClientEntry | OutOfViewClient | undefined = undefined;
private lastClientInfo: OutOfViewClient = undefined;
private lastClientInfo: OutOfViewClient;
private conversationOpen: boolean = false;
constructor(manager: PrivateConversationManager, events: Registry<PrivateConversationUIEvents>, client: ClientEntry | OutOfViewClient) {
super(manager.connection, client instanceof ClientEntry ? client.clientUid() : client.uniqueId, events);
constructor(manager: PrivateConversationManager, client: ClientEntry | OutOfViewClient) {
super(manager.connection, client instanceof ClientEntry ? client.clientUid() : client.uniqueId);
this.activeClient = client;
if(client instanceof ClientEntry) {
@ -48,11 +51,10 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.clientUniqueId = client.uniqueId;
}
this.updateClientInfo();
this.events.on("notify_destroy", () => this.unregisterClientEvents());
}
destroy() {
super.destroy();
this.unregisterClientEvents();
}
@ -62,10 +64,15 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
return this.lastClientInfo.clientId;
}
getLastClientInfo() : OutOfViewClient {
return this.lastClientInfo;
}
/* A value of undefined means that the remote client has disconnected */
setActiveClientEntry(client: ClientEntry | OutOfViewClient | undefined) {
if(this.activeClient === client)
if(this.activeClient === client) {
return;
}
if(this.activeClient instanceof ClientEntry) {
this.activeClient.setUnread(false); /* clear the unread flag */
@ -83,8 +90,9 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.unregisterClientEvents();
this.activeClient = client;
if(this.activeClient instanceof ClientEntry)
if(this.activeClient instanceof ClientEntry) {
this.registerClientEvents(this.activeClient);
}
this.updateClientInfo();
}
@ -100,11 +108,13 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.conversationOpen = true;
this.registerIncomingMessage(message, isOwnMessage, "m-" + this.clientUniqueId + "-" + message.timestamp + "-" + (++receivingEventUniqueIdIndex));
/* FIXME: notify_unread_count_changed */
}
handleChatRemotelyClosed(clientId: number) {
if(clientId !== this.lastClientInfo.clientId)
if(clientId !== this.lastClientInfo.clientId) {
return;
}
this.registerChatEvent({
type: "partner-action",
@ -133,47 +143,30 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.setActiveClientEntry(client);
}
handleRemoteComposing(clientId: number) {
this.events.fire("notify_partner_typing", { chatId: this.chatId });
}
generateUIInfo() : PrivateConversationInfo {
const lastMessage = this.presentEvents.last();
return {
nickname: this.lastClientInfo.nickname,
uniqueId: this.lastClientInfo.uniqueId,
clientId: this.lastClientInfo.clientId,
chatId: this.clientUniqueId,
lastMessage: lastMessage ? lastMessage.timestamp : 0,
unreadMessages: this.unreadTimestamp !== undefined
};
handleRemoteComposing(_clientId: number) {
this.events.fire("notify_partner_typing", { });
}
sendMessage(text: string) {
if(this.activeClient instanceof ClientEntry)
if(this.activeClient instanceof ClientEntry) {
this.doSendMessage(text, 1, this.activeClient.clientId()).then(succeeded => succeeded && (this.conversationOpen = true));
else if(this.activeClient !== undefined && this.activeClient.clientId > 0)
} else if(this.activeClient !== undefined && this.activeClient.clientId > 0) {
this.doSendMessage(text, 1, this.activeClient.clientId).then(succeeded => succeeded && (this.conversationOpen = true));
else {
this.presentEvents.push({
} else {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("target client is offline/invisible")
});
this.events.fire_react("notify_chat_event", {
chatId: this.chatId,
triggerUnread: false,
event: this.presentEvents.last()
});
}, false);
}
}
sendChatClose() {
if(!this.conversationOpen)
if(!this.conversationOpen) {
return;
}
this.conversationOpen = false;
if(this.lastClientInfo.clientId > 0 && this.connection.connected) {
@ -208,14 +201,16 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
private registerClientEvents(client: ClientEntry) {
this.activeClientListener = [];
this.activeClientListener.push(client.events.on("notify_properties_updated", event => {
if('client_nickname' in event.updated_properties)
if('client_nickname' in event.updated_properties) {
this.updateClientInfo();
}
}));
}
private unregisterClientEvents() {
if(this.activeClientListener === undefined)
if(this.activeClientListener === undefined) {
return;
}
this.activeClientListener.forEach(e => e());
this.activeClientListener = undefined;
@ -253,18 +248,16 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.sendMessageSendingEnabled(this.lastClientInfo.clientId !== 0);
}
setUnreadTimestamp(timestamp: number | undefined) {
setUnreadTimestamp(timestamp: number) {
super.setUnreadTimestamp(timestamp);
/* TODO: Move this somehow to the client itself? */
if(this.activeClient instanceof ClientEntry)
this.activeClient.setUnread(timestamp !== undefined);
/* TODO: Eliminate this cross reference? */
this.connection.side_bar.info_frame().update_chat_counter();
if(this.activeClient instanceof ClientEntry) {
this.activeClient.setUnread(this.isUnread());
}
}
protected canClientAccessChat(): boolean {
public canClientAccessChat(): boolean {
return true;
}
@ -282,24 +275,22 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
}
queryCurrentMessages() {
this.mode = "loading";
this.reportStateToUI();
this.setCurrentMode("loading");
queryConversationEvents(this.clientUniqueId, { limit: 50, begin: Date.now(), end: 0, direction: "backwards" }).then(result => {
this.presentEvents = result.events.filter(e => e.type !== "message") as any;
this.presentMessages = result.events.filter(e => e.type === "message");
this.hasHistory = result.hasMore;
this.mode = "normal";
this.setHistory(!!result.hasMore);
this.reportStateToUI();
this.setCurrentMode("normal");
});
}
protected registerChatEvent(event: ChatEvent, triggerUnread: boolean) {
public registerChatEvent(event: ChatEvent, triggerUnread: boolean) {
super.registerChatEvent(event, triggerUnread);
registerConversationEvent(this.clientUniqueId, event).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to register private conversation chat event for %s: %o"), this.clientUniqueId, error);
logWarn(LogCategory.CHAT, tr("Failed to register private conversation chat event for %s: %o"), this.clientUniqueId, error);
});
}
@ -320,164 +311,65 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
}
}
export class PrivateConversationManager extends AbstractChatManager<PrivateConversationUIEvents> {
public readonly htmlTag: HTMLDivElement;
export interface PrivateConversationManagerEvents extends AbstractChatManagerEvents<PrivateConversation> { }
export class PrivateConversationManager extends AbstractChatManager<PrivateConversationManagerEvents, PrivateConversation, PrivateConversationEvents> {
public readonly connection: ConnectionHandler;
private activeConversation: PrivateConversation | undefined = undefined;
private conversations: PrivateConversation[] = [];
private channelTreeInitialized = false;
constructor(connection: ConnectionHandler) {
super();
super(connection);
this.connection = connection;
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "row";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
this.uiEvents.register_handler(this, true);
this.uiEvents.enableDebug("private-conversations");
ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible)
return;
this.handlePanelShow();
}));
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
for(const chat of this.conversations) {
chat.handleLocalClientDisconnect(event.old_state === ConnectionState.CONNECTED);
}
this.listenerConnection.push(connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.oldState) !== ConnectionState.socketConnected(event.newState)) {
this.getConversations().forEach(conversation => {
conversation.handleLocalClientDisconnect(event.oldState === ConnectionState.CONNECTED);
});
this.channelTreeInitialized = false;
}
}));
this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_client_enter_view", event => {
this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => {
const conversation = this.findConversation(event.client);
if(!conversation) return;
conversation.handleClientEnteredView(event.client, this.channelTreeInitialized ? event.isServerJoin ? "server-join" : "appear" : "local-reconnect");
}));
this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_channel_list_received", event => {
this.listenerConnection.push(connection.channelTree.events.on("notify_channel_list_received", _event => {
this.channelTreeInitialized = true;
}));
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
super.destroy();
this.uiEvents.unregister_handler(this);
this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy();
this.listenerConnection.forEach(callback => callback());
this.listenerConnection.splice(0, this.listenerConnection.length);
}
findConversation(client: ClientEntry | string) {
const uniqueId = client instanceof ClientEntry ? client.clientUid() : client;
return this.conversations.find(e => e.clientUniqueId === uniqueId);
}
protected findChat(id: string): AbstractChat<PrivateConversationUIEvents> {
return this.findConversation(id);
return this.getConversations().find(e => e.clientUniqueId === uniqueId);
}
findOrCreateConversation(client: ClientEntry | OutOfViewClient) {
let conversation = this.findConversation(client instanceof ClientEntry ? client : client.uniqueId);
if(!conversation) {
this.conversations.push(conversation = new PrivateConversation(this, this.uiEvents, client));
this.reportConversationList();
conversation = new PrivateConversation(this, client);
this.registerConversation(conversation);
}
return conversation;
}
setActiveConversation(conversation: PrivateConversation | undefined) {
if(conversation === this.activeConversation)
return;
this.activeConversation = conversation;
/* fire this after all other events have been processed, maybe reportConversationList has been called before */
this.uiEvents.fire_react("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
}
@EventHandler<PrivateConversationUIEvents>("action_select_chat")
private handleActionSelectChat(event: PrivateConversationUIEvents["action_select_chat"]) {
this.setActiveConversation(this.findConversation(event.chatId));
}
getActiveConversation() {
return this.activeConversation;
}
getConversations() {
return this.conversations;
}
focusInput() {
this.uiEvents.fire("action_focus_chat");
}
closeConversation(...conversations: PrivateConversation[]) {
for(const conversation of conversations) {
conversation.sendChatClose();
this.conversations.remove(conversation);
this.unregisterConversation(conversation);
conversation.destroy();
if(this.activeConversation === conversation)
this.setActiveConversation(undefined);
}
this.reportConversationList();
}
private reportConversationList() {
this.uiEvents.fire_react("notify_private_conversations", {
conversations: this.conversations.map(conversation => conversation.generateUIInfo()),
selected: this.activeConversation?.clientUniqueId || "unselected"
});
}
@EventHandler<PrivateConversationUIEvents>("query_private_conversations")
private handleQueryPrivateConversations() {
this.reportConversationList();
}
@EventHandler<PrivateConversationUIEvents>("action_close_chat")
private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId);
return;
}
this.closeConversation(conversation);
}
@EventHandler<PrivateConversationUIEvents>("notify_partner_typing")
private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_selected_chat"]) {
/* TODO, set active chat? */
}
@EventHandler<ConversationUIEvents>("action_self_typing")
protected handleActionSelfTyping1(event: ConversationUIEvents["action_self_typing"]) {
if(!this.activeConversation)
return;
const clientId = this.activeConversation.currentClientId();
if(!clientId)
return;
this.connection.serverConnection.send_command("clientchatcomposing", { clid: clientId }).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to send chat composing to server for chat %d: %o"), clientId, error);
});
}
}

View File

@ -99,7 +99,7 @@ class IconManager extends AbstractIconManager {
return;
}
if(event.new_state !== ConnectionState.CONNECTED) {
if(event.newState !== ConnectionState.CONNECTED) {
return;
}

View File

@ -164,7 +164,7 @@ export class GroupManager extends AbstractCommandHandler {
this.connectionHandler = client;
this.connectionStateListener = (event: ConnectionEvents["notify_connection_state_changed"]) => {
if(event.new_state === ConnectionState.DISCONNECTING || event.new_state === ConnectionState.UNCONNECTED || event.new_state === ConnectionState.CONNECTING) {
if(event.newState === ConnectionState.DISCONNECTING || event.newState === ConnectionState.UNCONNECTED || event.newState === ConnectionState.CONNECTING) {
this.reset();
}
};

View File

@ -404,8 +404,9 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
icon_class: "client-channel_switch",
name: bold(tr("Join text channel")),
callback: () => {
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.getChannelId());
this.channelTree.client.side_bar.show_channel_conversations();
const conversation = this.channelTree.client.getChannelConversations().findOrCreateConversation(this.getChannelId());
this.channelTree.client.getChannelConversations().setSelectedConversation(conversation);
this.channelTree.client.side_bar.showChannelConversations();
},
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)
}, {
@ -563,7 +564,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
/* devel-block-end */
let info_update = false;
for(let variable of variables) {
let key = variable.key;
let value = variable.value;
@ -571,7 +571,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
if(key == "channel_name") {
this.parsed_channel_name = new ParsedChannelName(value, this.hasParent());
info_update = true;
} else if(key == "channel_order") {
let order = this.channelTree.findChannel(this.properties.channel_order);
this.channelTree.moveChannel(this, order, this.parent, true);
@ -599,13 +598,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
properties[property.key] = this.properties[property.key];
this.events.fire("notify_properties_updated", { updated_properties: properties as any, channel_properties: this.properties });
}
if(info_update) {
const _client = this.channelTree.client.getClient();
if(_client.currentChannel() === this)
this.channelTree.client.side_bar.info_frame().update_channel_talk();
//TODO chat channel!
}
}
generate_bbcode() {

View File

@ -166,22 +166,23 @@ export class ChannelTree {
if(this.selectedEntry instanceof ClientEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) {
if(this.selectedEntry instanceof MusicClientEntry) {
this.client.side_bar.show_music_player(this.selectedEntry);
this.client.side_bar.showMusicPlayer(this.selectedEntry);
} else {
this.client.side_bar.show_client_info(this.selectedEntry);
this.client.side_bar.showClientInfo(this.selectedEntry);
}
}
} else if(this.selectedEntry instanceof ChannelEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.client.side_bar.channel_conversations().setSelectedConversation(this.selectedEntry.channelId);
this.client.side_bar.show_channel_conversations();
const conversation = this.client.getChannelConversations().findOrCreateConversation(this.selectedEntry.channelId);
this.client.getChannelConversations().setSelectedConversation(conversation);
this.client.side_bar.showChannelConversations();
}
} else if(this.selectedEntry instanceof ServerEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
const sidebar = this.client.side_bar;
sidebar.channel_conversations().findOrCreateConversation(0);
sidebar.channel_conversations().setSelectedConversation(0);
sidebar.show_channel_conversations();
const conversation = this.client.getChannelConversations().findOrCreateConversation(0);
this.client.getChannelConversations().setSelectedConversation(conversation);
sidebar.showChannelConversations();
}
}
}
@ -373,7 +374,6 @@ export class ChannelTree {
if(oldChannel) {
this.events.fire("notify_client_leave_view", { client: client, message: reason.message, reason: reason.reason, isServerLeave: reason.serverLeave, sourceChannel: oldChannel });
this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
} else {
logWarn(LogCategory.CHANNEL, tr("Deleting client %s from channel tree which hasn't a channel."), client.clientId());
}
@ -458,14 +458,6 @@ export class ChannelTree {
client["_channel"] = targetChannel;
targetChannel?.registerClient(client);
if(oldChannel) {
this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
}
if(targetChannel) {
this.client.side_bar.info_frame().update_channel_client_count(targetChannel);
}
this.events.fire("notify_client_moved", { oldChannel: oldChannel, newChannel: targetChannel, client: client });
} finally {
flush_batched_updates(BatchUpdateType.CHANNEL_TREE);

View File

@ -382,7 +382,7 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
type: contextmenu.MenuEntryType.ENTRY,
name: this.properties.client_type_exact === ClientType.CLIENT_MUSIC ? tr("Show bot info") : tr("Show client info"),
callback: () => {
this.channelTree.client.side_bar.show_client_info(this);
this.channelTree.client.side_bar.showClientInfo(this);
},
icon_class: "client-about",
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)
@ -518,12 +518,13 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
}
open_text_chat() {
const chat = this.channelTree.client.side_bar;
const conversation = chat.private_conversations().findOrCreateConversation(this);
const privateConversations = this.channelTree.client.getPrivateConversations();
const sideBar = this.channelTree.client.side_bar;
const conversation = privateConversations.findOrCreateConversation(this);
conversation.setActiveClientEntry(this);
chat.private_conversations().setActiveConversation(conversation);
chat.show_private_conversations();
chat.private_conversations().focusInput();
privateConversations.setSelectedConversation(conversation);
sideBar.showPrivateConversations();
sideBar.private_conversations().focusInput();
}
showContextMenu(x: number, y: number, on_close: () => void = undefined) {

View File

@ -192,8 +192,8 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
icon_class: "client-channel_switch",
name: tr("Join server text channel"),
callback: () => {
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
this.channelTree.client.side_bar.show_channel_conversations();
this.channelTree.client.getChannelConversations().setSelectedConversation(0);
this.channelTree.client.side_bar.showChannelConversations();
},
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)
}, {

View File

@ -1,269 +1,10 @@
/* the bar on the right with the chats (Channel & Client) */
import {ClientEntry, MusicClientEntry} from "../../tree/Client";
import {ClientEntry, LocalClientEntry, MusicClientEntry} from "../../tree/Client";
import {ConnectionHandler} from "../../ConnectionHandler";
import {ChannelEntry} from "../../tree/Channel";
import {ServerEntry} from "../../tree/Server";
import {openMusicManage} from "../../ui/modal/ModalMusicManage";
import {formatMessage} from "../../ui/frames/chat";
import {MusicInfo} from "../../ui/frames/side/music_info";
import {ConversationManager} from "../../ui/frames/side/ConversationManager";
import {PrivateConversationManager} from "../../ui/frames/side/PrivateConversationManager";
import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons";
import { tr } from "tc-shared/i18n/localize";
import {ChannelConversationController} from "./side/ChannelConversationController";
import {PrivateConversationController} from "./side/PrivateConversationController";
import {ClientInfoController} from "tc-shared/ui/frames/side/ClientInfoController";
export enum InfoFrameMode {
NONE = "none",
CHANNEL_CHAT = "channel_chat",
PRIVATE_CHAT = "private_chat",
CLIENT_INFO = "client_info",
MUSIC_BOT = "music_bot"
}
export class InfoFrame {
private readonly handle: Frame;
private _html_tag: JQuery;
private _mode: InfoFrameMode;
private _value_ping: JQuery;
private _ping_updater: number;
private _channel_text: ChannelEntry;
private _channel_voice: ChannelEntry;
private _button_conversation: HTMLElement;
private _button_bot_manage: JQuery;
private _button_song_add: JQuery;
constructor(handle: Frame) {
this.handle = handle;
this._build_html_tag();
this.update_channel_talk();
this.update_channel_text();
this.set_mode(InfoFrameMode.CHANNEL_CHAT);
this._ping_updater = setInterval(() => this.update_ping(), 2000);
this.update_ping();
}
html_tag() : JQuery { return this._html_tag; }
destroy() {
clearInterval(this._ping_updater);
this._html_tag && this._html_tag.remove();
this._html_tag = undefined;
this._value_ping = undefined;
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat_info").renderTag();
this._html_tag.find(".button-switch-chat-channel").on('click', () => this.handle.show_channel_conversations());
this._value_ping = this._html_tag.find(".value-ping");
this._html_tag.find(".chat-counter").on('click', event => this.handle.show_private_conversations());
this._button_conversation = this._html_tag.find(".button.open-conversation").on('click', event => {
const selected_client = this.handle.getClientInfo().getClient();
if(!selected_client) return;
const conversation = selected_client ? this.handle.private_conversations().findOrCreateConversation(selected_client) : undefined;
if(!conversation) return;
this.handle.private_conversations().setActiveConversation(conversation);
this.handle.show_private_conversations();
})[0];
this._button_bot_manage = this._html_tag.find(".bot-manage").on('click', event => {
const bot = this.handle.music_info().current_bot();
if(!bot) return;
openMusicManage(this.handle.handle, bot);
});
this._button_song_add = this._html_tag.find(".bot-add-song").on('click', event => {
this.handle.music_info().events.fire("action_song_add");
});
}
update_ping() {
this._value_ping.removeClass("very-good good medium poor very-poor");
const connection = this.handle.handle.serverConnection;
if(!this.handle.handle.connected || !connection) {
this._value_ping.text("Not connected");
return;
}
const ping = connection.ping();
if(!ping || typeof(ping.native) !== "number") {
this._value_ping.text("Not available");
return;
}
let value;
if(typeof(ping.javascript) !== "undefined") {
value = ping.javascript;
this._value_ping.text(ping.javascript.toFixed(0) + "ms").attr('title', 'Native: ' + ping.native.toFixed(3) + "ms \nJavascript: " + ping.javascript.toFixed(3) + "ms");
} else {
value = ping.native;
this._value_ping.text(ping.native.toFixed(0) + "ms").attr('title', "Ping: " + ping.native.toFixed(3) + "ms");
}
if(value <= 10)
this._value_ping.addClass("very-good");
else if(value <= 30)
this._value_ping.addClass("good");
else if(value <= 60)
this._value_ping.addClass("medium");
else if(value <= 150)
this._value_ping.addClass("poor");
else
this._value_ping.addClass("very-poor");
}
update_channel_talk() {
const client = this.handle.handle.getClient();
const channel = client ? client.currentChannel() : undefined;
this._channel_voice = channel;
const html_tag = this._html_tag.find(".value-voice-channel");
const html_limit_tag = this._html_tag.find(".value-voice-limit");
html_limit_tag.text("");
html_tag.children().remove();
if(channel) {
if(channel.properties.channel_icon_id != 0) {
const connection = channel.channelTree.client;
generateIconJQueryTag(getIconManager().resolveIcon(channel.properties.channel_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag);
}
$.spawn("div").text(channel.formattedChannelName()).appendTo(html_tag);
this.update_channel_limit(channel, html_limit_tag);
} else {
$.spawn("div").text("Not connected").appendTo(html_tag);
}
}
update_channel_text() {
const channel_tree = this.handle.handle.connected ? this.handle.handle.channelTree : undefined;
const current_channel_id = channel_tree ? this.handle.channel_conversations().selectedConversation() : 0;
const channel = channel_tree ? channel_tree.findChannel(current_channel_id) : undefined;
this._channel_text = channel;
const tag_container = this._html_tag.find(".mode-channel_chat.channel");
const html_tag_title = tag_container.find(".title");
const html_tag = tag_container.find(".value-text-channel");
const html_limit_tag = tag_container.find(".value-text-limit");
/* reset */
html_tag_title.text(tr("You're chatting in Channel"));
html_limit_tag.text("");
html_tag.children().detach();
/* initialize */
if(channel) {
if(channel.properties.channel_icon_id != 0) {
const connection = channel.channelTree.client;
generateIconJQueryTag(getIconManager().resolveIcon(channel.properties.channel_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag);
}
$.spawn("div").text(channel.formattedChannelName()).appendTo(html_tag);
this.update_channel_limit(channel, html_limit_tag);
} else if(channel_tree && current_channel_id > 0) {
html_tag.append(formatMessage(tr("Unknown channel id {}"), current_channel_id));
} else if(channel_tree && current_channel_id == 0) {
const server = this.handle.handle.channelTree.server;
if(server.properties.virtualserver_icon_id != 0) {
const connection = server.channelTree.client;
generateIconJQueryTag(getIconManager().resolveIcon(server.properties.virtualserver_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag);
}
$.spawn("div").text(server.properties.virtualserver_name).appendTo(html_tag);
html_tag_title.text(tr("You're chatting in Server"));
this.update_server_limit(server, html_limit_tag);
} else if(this.handle.handle.connected) {
$.spawn("div").text("No channel selected").appendTo(html_tag);
} else {
$.spawn("div").text("Not connected").appendTo(html_tag);
}
}
update_channel_client_count(channel: ChannelEntry) {
if(channel === this._channel_text) {
this.update_channel_limit(channel, this._html_tag.find(".value-text-limit"));
}
if(channel === this._channel_voice) {
this.update_channel_limit(channel, this._html_tag.find(".value-voice-limit"));
}
}
private update_channel_limit(channel: ChannelEntry, tag: JQuery) {
let channel_limit = tr("Unlimited");
if(!channel.properties.channel_flag_maxclients_unlimited)
channel_limit = "" + channel.properties.channel_maxclients;
else if(!channel.properties.channel_flag_maxfamilyclients_unlimited) {
if(channel.properties.channel_maxfamilyclients >= 0)
channel_limit = "" + channel.properties.channel_maxfamilyclients;
}
tag.text(channel.clients(false).length + " / " + channel_limit);
}
private update_server_limit(server: ServerEntry, tag: JQuery) {
const fn = () => {
let text = server.properties.virtualserver_clientsonline + " / " + server.properties.virtualserver_maxclients;
if(server.properties.virtualserver_reserved_slots)
text += " (" + server.properties.virtualserver_reserved_slots + " " + tr("Reserved") + ")";
tag.text(text);
};
server.updateProperties().then(fn).catch(error => tag.text(tr("Failed to update info")));
fn();
}
update_chat_counter() {
const privateConversations = this.handle.private_conversations().getConversations();
{
const count = privateConversations.filter(e => e.hasUnreadMessages()).length;
const count_container = this._html_tag.find(".container-indicator");
const count_tag = count_container.find(".chat-unread-counter");
count_container.toggle(count > 0);
count_tag.text(count);
}
{
const count_tag = this._html_tag.find(".chat-counter");
if(privateConversations.length == 0)
count_tag.text(tr("No conversations"));
else if(privateConversations.length == 1)
count_tag.text(tr("One conversation"));
else
count_tag.text(privateConversations.length + " " + tr("conversations"));
}
}
current_mode() : InfoFrameMode {
return this._mode;
}
set_mode(mode: InfoFrameMode) {
for(const mode in InfoFrameMode)
this._html_tag.removeClass("mode-" + InfoFrameMode[mode]);
this._html_tag.addClass("mode-" + mode);
if(mode === InfoFrameMode.CLIENT_INFO && this._button_conversation) {
//Will be called every time a client is shown
const selected_client = this.handle.getClientInfo().getClient();
const conversation = selected_client ? this.handle.private_conversations().findConversation(selected_client) : undefined;
const visibility = (selected_client && selected_client.clientId() !== this.handle.handle.getClientId()) ? "visible" : "hidden";
if(this._button_conversation.style.visibility !== visibility)
this._button_conversation.style.visibility = visibility;
if(conversation) {
this._button_conversation.innerText = tr("Open conversation");
} else {
this._button_conversation.innerText = tr("Start a conversation");
}
} else if(mode === InfoFrameMode.MUSIC_BOT) {
//TODO?
}
}
}
import {SideHeader} from "tc-shared/ui/frames/side/HeaderController";
export enum FrameContent {
NONE,
@ -275,44 +16,43 @@ export enum FrameContent {
export class Frame {
readonly handle: ConnectionHandler;
private infoFrame: InfoFrame;
private htmlTag: JQuery;
private containerInfo: JQuery;
private containerChannelChat: JQuery;
private _content_type: FrameContent;
private header: SideHeader;
private clientInfo: ClientInfoController;
private musicInfo: MusicInfo;
private channelConversations: ConversationManager;
private privateConversations: PrivateConversationManager;
private channelConversations: ChannelConversationController;
private privateConversations: PrivateConversationController;
constructor(handle: ConnectionHandler) {
this.handle = handle;
this._content_type = FrameContent.NONE;
this.infoFrame = new InfoFrame(this);
this.privateConversations = new PrivateConversationManager(handle);
this.channelConversations = new ConversationManager(handle);
this.privateConversations = new PrivateConversationController(handle);
this.channelConversations = new ChannelConversationController(handle);
this.clientInfo = new ClientInfoController(handle);
this.musicInfo = new MusicInfo(this);
this.header = new SideHeader();
this._build_html_tag();
this.show_channel_conversations();
this.info_frame().update_chat_counter();
this.handle.events().one("notify_handler_initialized", () => this.header.setConnectionHandler(handle));
this.createHtmlTag();
this.showChannelConversations();
}
html_tag() : JQuery { return this.htmlTag; }
info_frame() : InfoFrame { return this.infoFrame; }
content_type() : FrameContent { return this._content_type; }
destroy() {
this.header?.destroy();
this.header = undefined;
this.htmlTag && this.htmlTag.remove();
this.htmlTag = undefined;
this.infoFrame && this.infoFrame.destroy();
this.infoFrame = undefined;
this.clientInfo?.destroy();
this.clientInfo = undefined;
@ -325,30 +65,21 @@ export class Frame {
this.channelConversations && this.channelConversations.destroy();
this.channelConversations = undefined;
this.containerInfo && this.containerInfo.remove();
this.containerInfo = undefined;
this.containerChannelChat && this.containerChannelChat.remove();
this.containerChannelChat = undefined;
}
private _build_html_tag() {
private createHtmlTag() {
this.htmlTag = $("#tmpl_frame_chat").renderTag();
this.containerInfo = this.htmlTag.find(".container-info");
this.htmlTag.find(".container-info").replaceWith(this.header.getHtmlTag());
this.containerChannelChat = this.htmlTag.find(".container-chat");
this.infoFrame.html_tag().appendTo(this.containerInfo);
}
private_conversations() : PrivateConversationManager {
private_conversations() : PrivateConversationController {
return this.privateConversations;
}
channel_conversations() : ConversationManager {
return this.channelConversations;
}
getClientInfo() : ClientInfoController {
return this.clientInfo;
}
@ -357,71 +88,73 @@ export class Frame {
return this.musicInfo;
}
private _clear() {
private clearSideBar() {
this._content_type = FrameContent.NONE;
this.containerChannelChat.children().detach();
}
show_private_conversations() {
showPrivateConversations() {
if(this._content_type === FrameContent.PRIVATE_CHAT)
return;
this._clear();
this.header.setState({ state: "conversation", mode: "private" });
this.clearSideBar();
this._content_type = FrameContent.PRIVATE_CHAT;
this.containerChannelChat.append(this.privateConversations.htmlTag);
this.privateConversations.handlePanelShow();
this.infoFrame.set_mode(InfoFrameMode.PRIVATE_CHAT);
}
show_channel_conversations() {
showChannelConversations() {
if(this._content_type === FrameContent.CHANNEL_CHAT)
return;
this._clear();
this.header.setState({ state: "conversation", mode: "channel" });
this.clearSideBar();
this._content_type = FrameContent.CHANNEL_CHAT;
this.containerChannelChat.append(this.channelConversations.htmlTag);
this.channelConversations.handlePanelShow();
this.infoFrame.set_mode(InfoFrameMode.CHANNEL_CHAT);
}
show_client_info(client: ClientEntry) {
showClientInfo(client: ClientEntry) {
this.clientInfo.setClient(client);
this.infoFrame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */
this.header.setState({ state: "client", ownClient: client instanceof LocalClientEntry });
if(this._content_type === FrameContent.CLIENT_INFO)
return;
this._clear();
this.clearSideBar();
this._content_type = FrameContent.CLIENT_INFO;
this.containerChannelChat.append(this.clientInfo.getHtmlTag());
}
show_music_player(client: MusicClientEntry) {
showMusicPlayer(client: MusicClientEntry) {
this.musicInfo.set_current_bot(client);
if(this._content_type === FrameContent.MUSIC_BOT)
return;
this.infoFrame.set_mode(InfoFrameMode.MUSIC_BOT);
this.header.setState({ state: "music-bot" });
this.musicInfo.previous_frame_content = this._content_type;
this._clear();
this.clearSideBar();
this._content_type = FrameContent.MUSIC_BOT;
this.containerChannelChat.append(this.musicInfo.html_tag());
}
set_content(type: FrameContent) {
if(this._content_type === type)
if(this._content_type === type) {
return;
}
if(type === FrameContent.CHANNEL_CHAT)
this.show_channel_conversations();
else if(type === FrameContent.PRIVATE_CHAT)
this.show_private_conversations();
else {
this._clear();
if(type === FrameContent.CHANNEL_CHAT) {
this.showChannelConversations();
} else if(type === FrameContent.PRIVATE_CHAT) {
this.showPrivateConversations();
} else {
this.header.setState({ state: "none" });
this.clearSideBar();
this._content_type = FrameContent.NONE;
this.infoFrame.set_mode(InfoFrameMode.NONE);
}
}
}

View File

@ -98,7 +98,7 @@ class InfoController {
const events = this.handlerRegisteredEvents;
events.push(handler.events().on("notify_connection_state_changed", event => {
if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) {
if(event.oldState === ConnectionState.CONNECTED || event.newState === ConnectionState.CONNECTED) {
this.sendHostButton();
this.sendVideoState("screen");
this.sendVideoState("camera");

View File

@ -0,0 +1,311 @@
import {
ChatHistoryState,
ConversationUIEvents
} from "../../../ui/frames/side/ConversationDefinitions";
import {EventHandler, Registry} from "../../../events";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {tra, tr} from "../../../i18n/localize";
import {
AbstractChat,
AbstractChatEvents,
AbstractChatManager,
AbstractChatManagerEvents
} from "tc-shared/conversations/AbstractConversion";
export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */
export abstract class AbstractConversationController<
Events extends ConversationUIEvents,
Manager extends AbstractChatManager<ManagerEvents, ConversationType, ConversationEvents>,
ManagerEvents extends AbstractChatManagerEvents<ConversationType>,
ConversationType extends AbstractChat<ConversationEvents>,
ConversationEvents extends AbstractChatEvents
> {
protected readonly uiEvents: Registry<Events>;
protected readonly conversationManager: Manager;
protected readonly listenerManager: (() => void)[];
private historyUiStates: {[id: string]: {
executingUIHistoryQuery: boolean,
historyErrorMessage: string | undefined,
historyRetryTimestamp: number
}} = {};
protected currentSelectedConversation: ConversationType;
protected currentSelectedListener: (() => void)[];
protected constructor(conversationManager: Manager) {
this.uiEvents = new Registry<Events>();
this.currentSelectedListener = [];
this.conversationManager = conversationManager;
this.listenerManager = [];
this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", event => {
this.currentSelectedListener.forEach(callback => callback());
this.currentSelectedListener = [];
this.currentSelectedConversation = event.newConversation;
this.uiEvents.fire_react("notify_selected_chat", { chatId: event.newConversation ? event.newConversation.getChatId() : "unselected" });
const conversation = event.newConversation;
if(conversation) {
this.registerConversationEvents(conversation);
}
}));
this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => {
delete this.historyUiStates[event.conversation.getChatId()];
}));
}
destroy() {
this.listenerManager.forEach(callback => callback());
this.listenerManager.splice(0, this.listenerManager.length);
this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy();
}
protected registerConversationEvents(conversation: ConversationType) {
this.currentSelectedListener.push(conversation.events.on("notify_unread_timestamp_changed", event =>
this.uiEvents.fire_react("notify_unread_timestamp_changed", { chatId: conversation.getChatId(), timestamp: event.timestamp })));
this.currentSelectedListener.push(conversation.events.on("notify_send_toggle", event =>
this.uiEvents.fire_react("notify_send_enabled", { chatId: conversation.getChatId(), enabled: event.enabled })));
this.currentSelectedListener.push(conversation.events.on("notify_chat_event", event => {
this.uiEvents.fire_react("notify_chat_event", { chatId: conversation.getChatId(), event: event.event, triggerUnread: event.triggerUnread });
}));
this.currentSelectedListener.push(conversation.events.on("notify_state_changed", () => {
this.reportStateToUI(conversation);
}));
this.currentSelectedListener.push(conversation.events.on("notify_history_state_changed", () => {
this.reportStateToUI(conversation);
}));
}
handlePanelShow() {
this.uiEvents.fire_react("notify_panel_show");
}
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) {
historyState = conversation.hasHistory() ? "available" : "none";
} else {
if(Date.now() < localHistoryState.historyRetryTimestamp && localHistoryState.historyErrorMessage) {
historyState = "error";
} else if(localHistoryState.executingUIHistoryQuery) {
historyState = "loading";
} else if(conversation.hasHistory()) {
historyState = "available";
} else {
historyState = "none";
}
}
switch (conversation.getCurrentMode()) {
case "normal":
if(conversation.isPrivate() && !conversation.canClientAccessChat()) {
this.uiEvents.fire_react("notify_conversation_state", {
chatId: conversation.getChatId(),
state: "private",
crossChannelChatSupported: crossChannelChatSupported
});
return;
}
this.uiEvents.fire_react("notify_conversation_state", {
chatId: conversation.getChatId(),
state: "normal",
historyState: historyState,
historyErrorMessage: localHistoryState?.historyErrorMessage,
historyRetryTimestamp: localHistoryState ? localHistoryState.historyRetryTimestamp : 0,
chatFrameMaxMessageCount: kMaxChatFrameMessageSize,
unreadTimestamp: conversation.getUnreadTimestamp(),
showUserSwitchEvents: conversation.isPrivate() || !crossChannelChatSupported,
sendEnabled: conversation.isSendEnabled(),
events: [...conversation.getPresentEvents(), ...conversation.getPresentMessages()]
});
break;
case "loading":
case "unloaded":
this.uiEvents.fire_react("notify_conversation_state", {
chatId: conversation.getChatId(),
state: "loading"
});
break;
case "error":
this.uiEvents.fire_react("notify_conversation_state", {
chatId: conversation.getChatId(),
state: "error",
errorMessage: conversation.getErrorMessage()
});
break;
case "no-permissions":
this.uiEvents.fire_react("notify_conversation_state", {
chatId: conversation.getChatId(),
state: "no-permissions",
failedPermission: conversation.getFailedPermission()
});
break;
}
}
public uiQueryHistory(conversation: AbstractChat<any>, timestamp: number, enforce?: boolean) {
const localHistoryState = this.historyUiStates[conversation.getChatId()] || (this.historyUiStates[conversation.getChatId()] = {
executingUIHistoryQuery: false,
historyErrorMessage: undefined,
historyRetryTimestamp: 0
});
if(localHistoryState.executingUIHistoryQuery && !enforce) {
return;
}
localHistoryState.executingUIHistoryQuery = true;
conversation.queryHistory({ end: 1, begin: timestamp, limit: kMaxChatFrameMessageSize }).then(result => {
localHistoryState.executingUIHistoryQuery = false;
localHistoryState.historyErrorMessage = undefined;
localHistoryState.historyRetryTimestamp = result.nextAllowedQuery;
switch (result.status) {
case "success":
this.uiEvents.fire_react("notify_conversation_history", {
chatId: conversation.getChatId(),
state: "success",
hasMoreMessages: result.moreEvents,
retryTimestamp: localHistoryState.historyRetryTimestamp,
events: result.events
});
break;
case "private":
this.uiEvents.fire_react("notify_conversation_history", {
chatId: conversation.getChatId(),
state: "error",
errorMessage: localHistoryState.historyErrorMessage = tr("chat is private"),
retryTimestamp: localHistoryState.historyRetryTimestamp
});
break;
case "no-permission":
this.uiEvents.fire_react("notify_conversation_history", {
chatId: conversation.getChatId(),
state: "error",
errorMessage: localHistoryState.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
retryTimestamp: localHistoryState.historyRetryTimestamp
});
break;
case "error":
this.uiEvents.fire_react("notify_conversation_history", {
chatId: conversation.getChatId(),
state: "error",
errorMessage: localHistoryState.historyErrorMessage = result.errorMessage,
retryTimestamp: localHistoryState.historyRetryTimestamp
});
break;
}
});
}
protected getCurrentConversation() : ConversationType | undefined {
return this.currentSelectedConversation;
}
@EventHandler<ConversationUIEvents>("query_conversation_state")
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
if(!conversation) {
this.uiEvents.fire_react("notify_conversation_state", {
state: "error",
errorMessage: tr("Unknown conversation"),
chatId: event.chatId
});
return;
}
if(conversation.getCurrentMode() === "unloaded") {
/* will switch the state to "loading" and already reports the state to the ui */
conversation.queryCurrentMessages();
} else {
this.reportStateToUI(conversation);
}
}
@EventHandler<ConversationUIEvents>("query_conversation_history")
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
if(!conversation) {
this.uiEvents.fire_react("notify_conversation_history", {
state: "error",
errorMessage: tr("Unknown conversation"),
retryTimestamp: Date.now() + 10 * 1000,
chatId: event.chatId
});
log.error(LogCategory.CLIENT, tr("Tried to query history for an unknown conversation with id %s"), event.chatId);
return;
}
this.uiQueryHistory(conversation, event.timestamp);
}
@EventHandler<ConversationUIEvents>(["action_clear_unread_flag", "action_self_typing"])
protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag" | "action_self_typing"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
conversation?.setUnreadTimestamp(Date.now());
}
@EventHandler<ConversationUIEvents>("action_send_message")
protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId);
return;
}
conversation.sendMessage(event.text);
}
@EventHandler<ConversationUIEvents>("action_jump_to_present")
protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId);
return;
}
this.reportStateToUI(conversation);
}
@EventHandler<ConversationUIEvents>("query_selected_chat")
private handleQuerySelectedChat() {
this.uiEvents.fire_react("notify_selected_chat", { chatId: this.currentSelectedConversation ? this.currentSelectedConversation.getChatId() : "unselected"})
}
@EventHandler<ConversationUIEvents>("action_select_chat")
private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
this.conversationManager.setSelectedConversation(conversation);
}
}

View File

@ -1,6 +1,5 @@
import * as React from "react";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import {ChatBox} from "tc-shared/ui/frames/side/ChatBox";
import {Ref, useEffect, useRef, useState} from "react";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
@ -23,8 +22,9 @@ import {BBCodeRenderer} from "tc-shared/text/bbcode";
import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars";
import {ColloquialFormat, date_format, format_date_general, formatDayTime} from "tc-shared/utils/DateUtils";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import {ChatBox} from "tc-shared/ui/react-elements/ChatBox";
const cssStyle = require("./ConversationUI.scss");
const cssStyle = require("./AbstractConversationRenderer.scss");
const ChatMessageTextRenderer = React.memo((props: { text: string }) => {
if(typeof props.text !== "string") { debugger; }
@ -664,8 +664,9 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
@EventHandler<ConversationUIEvents>("notify_selected_chat")
private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) {
if(this.currentChatId === event.chatId)
if(this.currentChatId === event.chatId) {
return;
}
this.currentChatId = event.chatId;
this.chatEvents = [];
@ -758,40 +759,33 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
@EventHandler<ConversationUIEvents>("notify_chat_message_delete")
private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) {
if(event.chatId !== this.currentChatId)
if(event.chatId !== this.currentChatId) {
return;
}
let limit = { current: event.criteria.limit };
this.chatEvents = this.chatEvents.filter(mEvent => {
if(mEvent.type !== "message")
return;
const message = mEvent.message;
if(message.sender_database_id !== event.criteria.cldbid)
return true;
if(event.criteria.end != 0 && message.timestamp > event.criteria.end)
return true;
if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin)
return true;
return --limit.current < 0;
});
this.chatEvents = this.chatEvents.filter(mEvent => event.messageIds.indexOf(mEvent.uniqueId) === -1);
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) {
if (event.chatId !== this.currentChatId || this.unreadTimestamp === undefined)
@EventHandler<ConversationUIEvents>("notify_unread_timestamp_changed")
private handleUnreadTimestampChanged(event: ConversationUIEvents["notify_unread_timestamp_changed"]) {
if (event.chatId !== this.currentChatId)
return;
this.unreadTimestamp = undefined;
this.buildView();
this.forceUpdate();
};
const oldUnreadTimestamp = this.unreadTimestamp;
if(this.chatEvents.last()?.timestamp > event.timestamp) {
this.unreadTimestamp = event.timestamp;
} else {
this.unreadTimestamp = undefined;
}
if(oldUnreadTimestamp !== this.unreadTimestamp) {
this.buildView();
this.forceUpdate();
}
}
@EventHandler<ConversationUIEvents>("notify_panel_show")
private handlePanelShow() {

View File

@ -1,395 +0,0 @@
import {
ChatEvent,
ChatEventMessage, ChatHistoryState, ChatMessage,
ChatState, ConversationHistoryResponse,
ConversationUIEvents
} from "../../../ui/frames/side/ConversationDefinitions";
import {ConnectionHandler} from "../../../ConnectionHandler";
import {EventHandler, Registry} from "../../../events";
import {preprocessChatMessageForSend} from "../../../text/chat";
import {CommandResult} from "../../../connection/ServerConnectionDeclaration";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {tra, tr} from "../../../i18n/localize";
import {ErrorCode} from "../../../connection/ErrorCode";
export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */
export abstract class AbstractChat<Events extends ConversationUIEvents> {
protected readonly connection: ConnectionHandler;
protected readonly chatId: string;
protected readonly events: Registry<Events>;
protected presentMessages: ChatEvent[] = [];
protected presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
protected mode: ChatState = "unloaded";
protected failedPermission: string;
protected errorMessage: string;
protected conversationPrivate: boolean = false;
protected crossChannelChatSupported: boolean = true;
protected unreadTimestamp: number | undefined = undefined;
protected lastReadMessage: number = 0;
protected historyErrorMessage: string;
protected historyRetryTimestamp: number = 0;
protected executingUIHistoryQuery = false;
protected messageSendEnabled: boolean = true;
protected hasHistory = false;
protected constructor(connection: ConnectionHandler, chatId: string, events: Registry<Events>) {
this.connection = connection;
this.events = events;
this.chatId = chatId;
}
public currentMode() : ChatState { return this.mode; };
protected registerChatEvent(event: ChatEvent, triggerUnread: boolean) {
if(event.type === "message") {
let index = 0;
while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= event.timestamp)
index++;
this.presentMessages.splice(index, 0, event);
const deleteMessageCount = Math.max(0, this.presentMessages.length - kMaxChatFrameMessageSize);
this.presentMessages.splice(0, deleteMessageCount);
if(deleteMessageCount > 0)
this.hasHistory = true;
index -= deleteMessageCount;
if(event.isOwnMessage) {
this.setUnreadTimestamp(undefined);
} else if(!this.unreadTimestamp) {
this.setUnreadTimestamp(event.message.timestamp);
}
/* let all other events run before */
this.events.fire_react("notify_chat_event", {
chatId: this.chatId,
triggerUnread: triggerUnread,
event: event
});
} else {
this.presentEvents.push(event);
this.presentEvents.sort((a, b) => a.timestamp - b.timestamp);
/* TODO: Cutoff too old events! */
this.events.fire("notify_chat_event", {
chatId: this.chatId,
triggerUnread: triggerUnread,
event: event
});
}
}
protected registerIncomingMessage(message: ChatMessage, isOwnMessage: boolean, uniqueId: string) {
this.registerChatEvent({
type: "message",
isOwnMessage: isOwnMessage,
uniqueId: uniqueId,
timestamp: message.timestamp,
message: message
}, !isOwnMessage);
}
public reportStateToUI() {
let historyState: ChatHistoryState;
if(Date.now() < this.historyRetryTimestamp && this.historyErrorMessage) {
historyState = "error";
} else if(this.executingUIHistoryQuery) {
historyState = "loading";
} else if(this.hasHistory) {
historyState = "available";
} else {
historyState = "none";
}
switch (this.mode) {
case "normal":
if(this.conversationPrivate && !this.canClientAccessChat()) {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "private",
crossChannelChatSupported: this.crossChannelChatSupported
});
return;
}
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "normal",
historyState: historyState,
historyErrorMessage: this.historyErrorMessage,
historyRetryTimestamp: this.historyRetryTimestamp,
chatFrameMaxMessageCount: kMaxChatFrameMessageSize,
unreadTimestamp: this.unreadTimestamp,
showUserSwitchEvents: this.conversationPrivate || !this.crossChannelChatSupported,
sendEnabled: this.messageSendEnabled,
events: [...this.presentEvents, ...this.presentMessages]
});
break;
case "loading":
case "unloaded":
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "loading"
});
break;
case "error":
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "error",
errorMessage: this.errorMessage
});
break;
case "no-permissions":
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "no-permissions",
failedPermission: this.failedPermission
});
break;
}
}
protected doSendMessage(message: string, targetMode: number, target: number) : Promise<boolean> {
let msg = preprocessChatMessageForSend(message);
return this.connection.serverConnection.send_command("sendtextmessage", {
targetmode: targetMode,
cid: target,
target: target,
msg: msg
}, { process_result: false }).then(async () => true).catch(error => {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "permission",
failedPermission: this.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknown")
}, false);
} else {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: error.formattedMessage()
}, false);
}
} else if(typeof error === "string") {
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: error
}, false);
} else {
log.warn(LogCategory.CHAT, tr("Failed to send channel chat message to %s: %o"), this.chatId, error);
this.registerChatEvent({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("lookup the console")
}, false);
}
return false;
});
}
public isUnread() {
return this.unreadTimestamp !== undefined;
}
public setUnreadTimestamp(timestamp: number | undefined) {
if(timestamp === undefined)
this.lastReadMessage = Date.now();
if(this.unreadTimestamp === timestamp)
return;
this.unreadTimestamp = timestamp;
this.events.fire_react("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
}
public jumpToPresent() {
this.reportStateToUI();
}
public uiQueryHistory(timestamp: number, enforce?: boolean) {
if(this.executingUIHistoryQuery && !enforce)
return;
this.executingUIHistoryQuery = true;
this.queryHistory({ end: 1, begin: timestamp, limit: kMaxChatFrameMessageSize }).then(result => {
this.executingUIHistoryQuery = false;
this.historyErrorMessage = undefined;
this.historyRetryTimestamp = result.nextAllowedQuery;
switch (result.status) {
case "success":
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "success",
hasMoreMessages: result.moreEvents,
retryTimestamp: this.historyRetryTimestamp,
events: result.events
});
break;
case "private":
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tr("chat is private"),
retryTimestamp: this.historyRetryTimestamp
});
break;
case "no-permission":
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
retryTimestamp: this.historyRetryTimestamp
});
break;
case "error":
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = result.errorMessage,
retryTimestamp: this.historyRetryTimestamp
});
break;
}
});
}
protected lastEvent() : ChatEvent | undefined {
if(this.presentMessages.length === 0)
return this.presentEvents.last();
else if(this.presentEvents.length === 0 || this.presentMessages.last().timestamp > this.presentEvents.last().timestamp)
return this.presentMessages.last();
else
return this.presentEvents.last();
}
protected sendMessageSendingEnabled(enabled: boolean) {
if(this.messageSendEnabled === enabled)
return;
this.messageSendEnabled = enabled;
this.events.fire("notify_send_enabled", { chatId: this.chatId, enabled: enabled });
}
protected abstract canClientAccessChat() : boolean;
public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse>;
public abstract queryCurrentMessages();
public abstract sendMessage(text: string);
}
export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
protected readonly uiEvents: Registry<Events>;
protected constructor() {
this.uiEvents = new Registry<Events>();
}
handlePanelShow() {
this.uiEvents.fire("notify_panel_show");
}
protected abstract findChat(id: string) : AbstractChat<Events>;
@EventHandler<ConversationUIEvents>("query_conversation_state")
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_react("notify_conversation_state", {
state: "error",
errorMessage: tr("Unknown conversation"),
chatId: event.chatId
});
return;
}
if(conversation.currentMode() === "unloaded") {
conversation.queryCurrentMessages();
} else {
conversation.reportStateToUI();
}
}
@EventHandler<ConversationUIEvents>("query_conversation_history")
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_react("notify_conversation_history", {
state: "error",
errorMessage: tr("Unknown conversation"),
retryTimestamp: Date.now() + 10 * 1000,
chatId: event.chatId
});
log.error(LogCategory.CLIENT, tr("Tried to query history for an unknown conversation with id %s"), event.chatId);
return;
}
conversation.uiQueryHistory(event.timestamp);
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.findChat(event.chatId)?.setUnreadTimestamp(undefined);
}
@EventHandler<ConversationUIEvents>("action_self_typing")
protected handleActionSelfTyping(event: ConversationUIEvents["action_self_typing"]) {
if(this.findChat(event.chatId)?.isUnread())
this.uiEvents.fire("action_clear_unread_flag", { chatId: event.chatId });
}
@EventHandler<ConversationUIEvents>("action_send_message")
protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId);
return;
}
conversation.sendMessage(event.text);
}
@EventHandler<ConversationUIEvents>("action_jump_to_present")
protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId);
return;
}
conversation.jumpToPresent();
}
}

View File

@ -0,0 +1,91 @@
import * as React from "react";
import {ConnectionHandler} 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 {ConversationPanel} from "./AbstractConversationRenderer";
import {AbstractConversationController} from "./AbstractConversationController";
import {
ChannelConversation, ChannelConversationEvents,
ChannelConversationManager,
ChannelConversationManagerEvents
} from "tc-shared/conversations/ChannelConversationManager";
export class ChannelConversationController extends AbstractConversationController<
ConversationUIEvents,
ChannelConversationManager,
ChannelConversationManagerEvents,
ChannelConversation,
ChannelConversationEvents
> {
readonly connection: ConnectionHandler;
readonly htmlTag: HTMLDivElement;
constructor(connection: ConnectionHandler) {
super(connection.getChannelConversations() as any);
this.connection = connection;
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "column";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
ReactDOM.render(React.createElement(ConversationPanel, {
events: this.uiEvents,
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}), this.htmlTag);
/*
spawnExternalModal("conversation", this.uiEvents, {
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}).open().then(() => {
console.error("Opened");
});
*/
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible) {
return;
}
this.handlePanelShow();
}));
this.uiEvents.register_handler(this, true);
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
this.uiEvents.unregister_handler(this);
super.destroy();
}
@EventHandler<ConversationUIEvents>("action_delete_message")
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
const conversation = this.conversationManager.findConversationById(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId);
return;
}
conversation.deleteMessage(event.uniqueId);
}
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() });
}));
}
}

View File

@ -95,7 +95,7 @@ export class ClientInfoController {
}));
this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", event => {
if(event.new_state !== ConnectionState.CONNECTED && this.currentClientStatus) {
if(event.newState !== ConnectionState.CONNECTED && this.currentClientStatus) {
this.currentClient = undefined;
this.currentClientStatus.leaveTimestamp = Date.now() / 1000;
this.sendOnline();
@ -196,7 +196,7 @@ export class ClientInfoController {
microphoneMuted: client.properties.client_input_muted,
microphoneDisabled: !client.properties.client_input_hardware,
speakerMuted: client.properties.client_output_muted,
speakerDisabled: client.properties.client_output_hardware
speakerDisabled: !client.properties.client_output_hardware
};
}
@ -250,6 +250,11 @@ export class ClientInfoController {
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlContainer);
this.listenerClient.forEach(callback => callback());
this.listenerClient = [];
this.listenerConnection.forEach(callback => callback());
this.listenerConnection.splice(0, this.listenerConnection.length);
}

View File

@ -23,7 +23,7 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {getIconManager} from "tc-shared/file/Icons";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
const cssStyle = require("./ClientInfo.scss");
const cssStyle = require("./ClientInfoRenderer.scss");
const EventsContext = React.createContext<Registry<ClientInfoEvents>>(undefined);
const ClientContext = React.createContext<OptionalClientInfoInfo>(undefined);

View File

@ -9,6 +9,7 @@ export interface ChatMessage {
/* ---------- Chat events ---------- */
export type ChatEvent = { timestamp: number; uniqueId: string; } & (
ChatEventUnreadTrigger |
ChatEventMessage |
ChatEventMessageSendFailed |
ChatEventLocalUserSwitch |
@ -18,6 +19,10 @@ export type ChatEvent = { timestamp: number; uniqueId: string; } & (
ChatEventPartnerAction
);
export interface ChatEventUnreadTrigger {
type: "unread-trigger";
}
export interface ChatEventMessageSendFailed {
type: "message-failed";
@ -136,11 +141,11 @@ export interface ConversationUIEvents {
},
notify_chat_message_delete: {
chatId: string,
criteria: { begin: number, end: number, cldbid: number, limit: number }
messageIds: string[]
},
notify_unread_timestamp_changed: {
chatId: string,
timestamp: number | undefined
timestamp: number,
}
notify_private_state_changed: {
chatId: string,

View File

@ -0,0 +1,269 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import * as ReactDOM from "react-dom";
import {SideHeaderRenderer} from "./HeaderRenderer";
import * as React from "react";
import {SideHeaderEvents, SideHeaderState} from "tc-shared/ui/frames/side/HeaderDefinitions";
import * as _ from "lodash";
import {Registry} from "tc-shared/events";
import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel";
import {ClientEntry, LocalClientEntry} from "tc-shared/tree/Client";
import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage";
const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [
"channel_name",
"channel_icon_id",
"channel_flag_maxclients_unlimited",
"channel_maxclients",
"channel_flag_maxfamilyclients_inherited",
"channel_flag_maxfamilyclients_unlimited",
"channel_maxfamilyclients"
];
/* TODO: Remove the ping interval handler. It's currently still there since the clients are not emiting the event yet */
export class SideHeader {
private readonly htmlTag: HTMLDivElement;
private readonly uiEvents: Registry<SideHeaderEvents>;
private connection: ConnectionHandler;
private listenerConnection: (() => void)[];
private listenerVoiceChannel: (() => void)[];
private listenerTextChannel: (() => void)[];
private currentState: SideHeaderState;
private currentVoiceChannel: ChannelEntry;
private currentTextChannel: ChannelEntry;
private pingUpdateInterval: number;
constructor() {
this.uiEvents = new Registry<SideHeaderEvents>();
this.listenerConnection = [];
this.listenerVoiceChannel = [];
this.listenerTextChannel = [];
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "column";
this.htmlTag.style.flexShrink = "0";
this.htmlTag.style.flexGrow = "0";
ReactDOM.render(React.createElement(SideHeaderRenderer, { events: this.uiEvents }), this.htmlTag);
this.initialize();
}
private initialize() {
this.uiEvents.on("action_open_conversation", () => {
const selectedClient = this.connection.side_bar.getClientInfo().getClient()
if(selectedClient) {
const conversations = this.connection.getPrivateConversations();
conversations.setSelectedConversation(conversations.findOrCreateConversation(selectedClient));
}
this.connection.side_bar.showPrivateConversations();
});
this.uiEvents.on("action_switch_channel_chat", () => {
this.connection.side_bar.showChannelConversations();
});
this.uiEvents.on("action_bot_manage", () => {
const bot = this.connection.side_bar.music_info().current_bot();
if(!bot) return;
openMusicManage(this.connection, bot);
});
this.uiEvents.on("action_bot_manage", () => this.connection.side_bar.music_info().events.fire("action_song_add"));
this.uiEvents.on("query_current_channel_state", event => this.sendChannelState(event.mode));
this.uiEvents.on("query_private_conversations", () => this.sendPrivateConversationInfo());
this.uiEvents.on("query_ping", () => this.sendPing());
}
private initializeConnection() {
this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_moved", event => {
if(event.client instanceof LocalClientEntry) {
this.updateVoiceChannel();
}
}));
this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_enter_view", event => {
if(event.client instanceof LocalClientEntry) {
this.updateVoiceChannel();
}
}));
this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", () => {
this.updateVoiceChannel();
this.updateTextChannel();
this.sendPing();
if(this.connection.connected) {
if(!this.pingUpdateInterval) {
this.pingUpdateInterval = setInterval(() => this.sendPing(), 2000);
}
} else if(this.pingUpdateInterval) {
clearInterval(this.pingUpdateInterval);
this.pingUpdateInterval = undefined;
}
}));
this.listenerConnection.push(this.connection.getChannelConversations().events.on("notify_selected_changed", () => this.updateTextChannel()));
this.listenerConnection.push(this.connection.serverConnection.events.on("notify_ping_updated", () => this.sendPing()));
this.listenerConnection.push(this.connection.getPrivateConversations().events.on("notify_unread_count_changed", () => this.sendPrivateConversationInfo()));
this.listenerConnection.push(this.connection.getPrivateConversations().events.on(["notify_conversation_destroyed", "notify_conversation_destroyed"], () => this.sendPrivateConversationInfo()));
}
setConnectionHandler(connection: ConnectionHandler) {
if(this.connection === connection) {
return;
}
this.listenerConnection.forEach(callback => callback());
this.listenerConnection = [];
this.connection = connection;
if(connection) {
this.initializeConnection();
/* TODO: Update state! */
} else {
this.setState({ state: "none" });
}
}
getConnectionHandler() : ConnectionHandler | undefined {
return this.connection;
}
getHtmlTag() : HTMLDivElement {
return this.htmlTag;
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.listenerConnection.forEach(callback => callback());
this.listenerConnection = [];
this.listenerTextChannel.forEach(callback => callback());
this.listenerTextChannel = [];
this.listenerVoiceChannel.forEach(callback => callback());
this.listenerVoiceChannel = [];
clearInterval(this.pingUpdateInterval);
this.pingUpdateInterval = undefined;
}
setState(state: SideHeaderState) {
if(_.isEqual(this.currentState, state)) {
return;
}
this.currentState = state;
this.uiEvents.fire_react("notify_header_state", { state: state });
}
private sendChannelState(mode: "voice" | "text") {
const channel = mode === "voice" ? this.currentVoiceChannel : this.currentTextChannel;
if(channel) {
let maxClients = -1;
if(!channel.properties.channel_flag_maxclients_unlimited) {
maxClients = channel.properties.channel_maxclients;
}
this.uiEvents.fire_react("notify_current_channel_state", {
mode: mode,
state: {
state: "connected",
channelName: channel.parsed_channel_name.text,
channelIcon: {
handlerId: this.connection.handlerId,
serverUniqueId: this.connection.getCurrentServerUniqueId(),
iconId: channel.properties.channel_icon_id
},
channelUserCount: channel.clients(false).length,
channelMaxUser: maxClients
}
});
} else {
this.uiEvents.fire_react("notify_current_channel_state", { mode: mode, state: { state: "not-connected" }});
}
}
private updateVoiceChannel() {
let targetChannel = this.connection?.connected ? this.connection.getClient().currentChannel() : undefined;
if(this.currentVoiceChannel === targetChannel) {
return;
}
this.listenerVoiceChannel.forEach(callback => callback());
this.listenerVoiceChannel = [];
this.currentVoiceChannel = targetChannel;
this.sendChannelState("voice");
if(targetChannel) {
this.listenerTextChannel.push(targetChannel.events.on("notify_properties_updated", event => {
for(const key of ChannelInfoUpdateProperties) {
if(key in event.updated_properties) {
this.sendChannelState("voice");
return;
}
}
}));
}
}
private updateTextChannel() {
let targetChannel: ChannelEntry;
let targetChannelId = this.connection?.connected ? parseInt(this.connection.getChannelConversations().getSelectedConversation()?.getChatId()) : -1;
if(!isNaN(targetChannelId) && targetChannelId >= 0) {
targetChannel = this.connection.channelTree.findChannel(targetChannelId);
}
if(this.currentTextChannel === targetChannel) {
return;
}
this.listenerTextChannel.forEach(callback => callback());
this.listenerTextChannel = [];
this.currentTextChannel = targetChannel;
this.sendChannelState("text");
if(targetChannel) {
this.listenerTextChannel.push(targetChannel.events.on("notify_properties_updated", event => {
for(const key of ChannelInfoUpdateProperties) {
if(key in event.updated_properties) {
this.sendChannelState("text");
return;
}
}
}));
}
}
private sendPing() {
if(this.connection?.connected) {
const ping = this.connection.getServerConnection().ping();
this.uiEvents.fire_react("notify_ping", {
ping: {
native: typeof ping.native !== "number" ? -1 : ping.native,
javaScript: ping.javascript
}
});
} else {
this.uiEvents.fire_react("notify_ping", { ping: undefined });
}
}
private sendPrivateConversationInfo() {
const conversations = this.connection.getPrivateConversations();
this.uiEvents.fire_react("notify_private_conversations", {
info: {
open: conversations.getConversations().length,
unread: conversations.getUnreadCount()
}
});
}
}

View File

@ -0,0 +1,65 @@
import {RemoteIconInfo} from "tc-shared/file/Icons";
export type SideHeaderState = SideHeaderStateNone | SideHeaderStateConversation | SideHeaderStateClient | SideHeaderStateMusicBot;
export type SideHeaderStateNone = {
state: "none"
};
export type SideHeaderStateConversation = {
state: "conversation",
mode: "channel" | "private"
};
export type SideHeaderStateClient = {
state: "client",
ownClient: boolean
}
export type SideHeaderStateMusicBot = {
state: "music-bot"
}
export type SideHeaderChannelState = {
state: "not-connected"
} | {
state: "connected",
channelName: string,
channelIcon: RemoteIconInfo,
channelUserCount: number,
channelMaxUser: number | -1
};
export type SideHeaderPingInfo = {
native: number,
javaScript: number | undefined
};
export type PrivateConversationInfo = {
unread: number,
open: number
};
export interface SideHeaderEvents {
action_bot_manage: {},
action_bot_add_song: {},
action_switch_channel_chat: {},
action_open_conversation: {},
query_current_channel_state: { mode: "voice" | "text" },
query_ping: {},
query_private_conversations: {},
notify_header_state: {
state: SideHeaderState
},
notify_current_channel_state: {
mode: "voice" | "text",
state: SideHeaderChannelState
},
notify_ping: {
ping: SideHeaderPingInfo | undefined
},
notify_private_conversations: {
info: PrivateConversationInfo
}
}

View File

@ -0,0 +1,186 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
@import "./ClientInfoRenderer";
.container {
user-select: none;
flex-grow: 0;
flex-shrink: 0;
height: 9em;
display: flex;
flex-direction: column;
justify-content: space-evenly;
background-color: var(--side-info-background);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
-moz-box-shadow: inset 0 0 5px var(--side-info-shadow);
-webkit-box-shadow: inset 0 0 5px var(--side-info-shadow);
box-shadow: inset 0 0 5px var(--side-info-shadow);
.lane {
padding-right: 10px;
padding-left: 10px;
display: flex;
flex-direction: row;
justify-content: stretch;
height: 3.25em;
max-width: 100%;
overflow: hidden;
.block, .button {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.block {
flex-shrink: 1;
flex-grow: 1;
min-width: 0;
&.right {
text-align: right;
&.mode-client_info {
max-width: calc(50% - #{$client_info_avatar_size / 2});
margin-left: calc(#{$client_info_avatar_size / 2});
}
}
&.left {
margin-right: .5em;
text-align: left;
padding-right: 10px;
&.mode-client_info {
max-width: calc(50% - #{$client_info_avatar_size / 2});
margin-right: calc(#{$client_info_avatar_size} / 2);
}
}
.title, .value, .smallValue {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 0;
max-width: 100%;
}
.title {
display: block;
color: var(--side-info-title);
.containerIndicator {
display: inline-flex;
flex-direction: column;
justify-content: space-around;
background: var(--side-info-indicator-background);
border: 1px solid var(--side-info-indicator-border);
border-radius: 4px;
text-align: center;
vertical-align: text-top;
color: var(--side-info-indicator);
font-size: .66em;
height: 1.3em;
min-width: .9em;
padding-right: 2px;
padding-left: 2px;
}
}
.value {
color: var(--side-info-value);
background-color: var(--side-info-value-background);
display: inline-block;
border-radius: .18em;
padding-right: .31em;
padding-left: .31em;
> div {
display: inline-block;
}
.icon {
vertical-align: text-top;
margin-right: .25em;
}
&.ping {
&.veryGood {
color: var(--side-info-ping-very-good);
}
&.good {
color: var(--side-info-ping-good);
}
&.medium {
color: var(--side-info-ping-medium);
}
&.poor {
color: var(--side-info-ping-poor);
}
&.veryPoor {
color: var(--side-info-ping-very-poor);
}
}
}
.smallValue {
display: inline-block;
color: var(--side-info-value);
font-size: .66em;
vertical-align: top;
margin-top: -.2em;
margin-left: .25em;
}
.button {
color: var(--side-info-value);
background-color: var(--side-info-value-background);
display: inline-block;
cursor: pointer;
&.botAddSong {
color: var(--side-info-bot-add-song);
}
&:hover {
background-color: #4e4e4e; /* TODO: Evaluate color */
}
@include transition(background-color $button_hover_animation_time ease-in-out);
}
}
&.musicBotInfo {
.right {
margin-left: 8.5em;
}
.left {
margin-right: 8.5em;
}
width: 60em; /* same width so flex-shrik applies equaly */
}
}
}

View File

@ -0,0 +1,281 @@
import * as React from "react";
import {Registry} from "tc-shared/events";
import {
SideHeaderEvents,
SideHeaderState,
SideHeaderChannelState,
SideHeaderPingInfo, PrivateConversationInfo
} from "tc-shared/ui/frames/side/HeaderDefinitions";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {useContext, useState} from "react";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {getIconManager} from "tc-shared/file/Icons";
const StateContext = React.createContext<SideHeaderState>(undefined);
const EventsContext = React.createContext<Registry<SideHeaderEvents>>(undefined);
const cssStyle = require("./HeaderRenderer.scss");
const Block = (props: { children: [React.ReactElement, React.ReactElement], target: "left" | "right" }) => (
<div className={cssStyle.block + " " + cssStyle[props.target]}>
<div className={cssStyle.title}>{props.children[0]}</div>
{props.children[1]}
</div>
)
const ChannelStateRenderer = (props: { info: SideHeaderChannelState }) => {
if(props.info.state === "not-connected") {
return <div className={cssStyle.value} key={"not-connected"}><Translatable>Not connected</Translatable></div>;
} else {
let limit;
if(props.info.channelMaxUser === -1) {
limit = <Translatable key={"unlimited"}>Unlimited</Translatable>
} else {
limit = props.info.channelMaxUser;
}
let icon;
if(props.info.channelIcon.iconId !== 0) {
const remoteIcon = getIconManager().resolveIcon(props.info.channelIcon.iconId, props.info.channelIcon.serverUniqueId, props.info.channelIcon.handlerId);
icon = <RemoteIconRenderer icon={remoteIcon} className={cssStyle.icon} key={"icon-" + props.info.channelIcon.iconId} />;
}
return (
<React.Fragment key={"connected"}>
<div className={cssStyle.value}>{icon}{props.info.channelName}</div>
<div className={cssStyle.smallValue}>{props.info.channelUserCount} / {limit}</div>
</React.Fragment>
);
}
}
const BlockChannelState = (props: { mode: "voice" | "text" }) => {
const events = useContext(EventsContext);
const [ info, setInfo ] = useState<SideHeaderChannelState>(() => {
events.fire("query_current_channel_state", { mode: props.mode });
return { state: "not-connected" };
});
events.reactUse("notify_current_channel_state", event => event.mode === props.mode && setInfo(event.state));
let title;
if(props.mode === "voice") {
title = <Translatable key={"voice"}>You're talking in Channel</Translatable>;
} else {
title = <Translatable key={"text"}>You're chatting in Channel</Translatable>;
}
return (
<Block target={"left"}>
{title}
<ChannelStateRenderer info={info} />
</Block>
);
}
const BlockPing = () => {
const events = useContext(EventsContext);
const [ pingInfo, setPingInfo ] = useState<SideHeaderPingInfo>(() => {
events.fire("query_ping");
return undefined;
});
events.reactUse("notify_ping", event => setPingInfo(event.ping));
let value, title;
if(!pingInfo) {
value = (
<div className={cssStyle.value} key={"not-connected"} title={tr("You're not connected to any server")}>
<Translatable>Not connected</Translatable>
</div>
);
} else {
let pingClass;
if(pingInfo.native <= 30) {
pingClass = cssStyle.veryGood;
} else if(pingInfo.native <= 50) {
pingClass = cssStyle.good;
} else if(pingInfo.native <= 90) {
pingClass = cssStyle.medium;
} else if(pingInfo.native <= 200) {
pingClass = cssStyle.poor;
} else {
pingClass = cssStyle.veryPoor;
}
if(pingInfo.javaScript === undefined) {
title = tr("Ping: " + pingInfo.native.toFixed(3) + "ms");
} else {
title = tr("Native: " + pingInfo.native.toFixed(3) + "ms\nJavascript: " + pingInfo.javaScript.toFixed(3) + "ms");
}
value = <div className={cssStyle.value + " " + cssStyle.ping + " " + pingClass} key={"ping"} title={title}>{pingInfo.native.toFixed(0)}ms</div>;
}
return (
<Block target={"right"}>
<Translatable>Your Ping</Translatable>
{value}
</Block>
);
};
const BlockPrivateChats = (props: { asButton: boolean }) => {
const events = useContext(EventsContext);
const [ info, setInfo ] = useState<PrivateConversationInfo>(() => {
events.fire("query_private_conversations");
return { unread: 0, open: 0 };
});
events.reactUse("notify_private_conversations", event => setInfo(event.info));
let body;
if(info.open === 0) {
body = <Translatable key={"no-conversations"}>No conversations</Translatable>;
} else if(info.open === 1) {
body = <Translatable key={"1-conversations"}>One conversation</Translatable>;
} else {
body = <VariadicTranslatable text={"{} conversations"} key={"n-conversations"}>{info.open}</VariadicTranslatable>;
}
let title;
if(info.unread === 0) {
title = <Translatable key={"zero"}>Private Chats</Translatable>;
} else {
title = (
<React.Fragment key={"unread"}>
<Translatable>Private Chats</Translatable>
<div className={cssStyle.containerIndicator}>
{info.unread}
</div>
</React.Fragment>
)
}
return (
<Block target={"right"}>
{title}
<div className={cssStyle.value + " " + (props.asButton ? cssStyle.button : "")} onClick={() => props.asButton && events.fire("action_open_conversation")}>
{body}
</div>
</Block>
);
}
const BlockButtonSwitchToChannelChat = () => {
const events = useContext(EventsContext);
return (
<Block target={"left"}>
<>&nbsp;</>
<div className={cssStyle.value + " " + cssStyle.button} onClick={() => events.fire("action_switch_channel_chat")}>
<Translatable>Switch to channel chat</Translatable>
</div>
</Block>
)
}
const BlockButtonOpenConversation = () => {
const events = useContext(EventsContext);
return (
<Block target={"right"}>
<>&nbsp;</>
<div className={cssStyle.value + " " + cssStyle.button} onClick={() => events.fire("action_open_conversation")}>
<Translatable>Open conversation</Translatable>
</div>
</Block>
)
}
const BlockButtonBotManage = () => {
const events = useContext(EventsContext);
return (
<Block target={"left"}>
<>&nbsp;</>
<div className={cssStyle.value + " " + cssStyle.button} onClick={() => events.fire("action_bot_manage")}>
<Translatable>Manage bot</Translatable>
</div>
</Block>
)
}
const BlockButtonBotSongAdd = () => {
const events = useContext(EventsContext);
return (
<Block target={"right"}>
<>&nbsp;</>
<div className={cssStyle.value + " " + cssStyle.button + " " + cssStyle.botAddSong} onClick={() => events.fire("action_bot_add_song")}>
<Translatable>Add song</Translatable>
</div>
</Block>
)
}
const BlockTopLeft = () => <BlockChannelState mode={"voice"} />;
const BlockTopRight = () => <BlockPing />;
const BlockBottomLeft = () => {
const state = useContext(StateContext);
switch (state.state) {
case "conversation":
if(state.mode === "private") {
return <BlockButtonSwitchToChannelChat key={"switch-channel-chat"} />;
} else {
return <BlockChannelState mode={"text"} key={"text-state"} />;
}
case "music-bot":
return <BlockButtonBotManage key={"button-manage-bot"} />;
case "none":
case "client":
default:
return null;
}
}
const BlockBottomRight = () => {
const state = useContext(StateContext);
switch (state.state) {
case "client":
if(state.ownClient) {
return null;
} else {
return <BlockButtonOpenConversation key={"button-open-conversation"} />;
}
case "conversation":
return <BlockPrivateChats key={"conversation"} asButton={state.mode !== "private"} />;
case "music-bot":
return <BlockButtonBotSongAdd key={"button-add-song"} />;
case "none":
default:
return null;
}
}
export const SideHeaderRenderer = (props: { events: Registry<SideHeaderEvents> }) => {
const [ state, setState ] = useState<SideHeaderState>({ state: "none" });
props.events.reactUse("notify_header_state", event => setState(event.state));
return (
<EventsContext.Provider value={props.events}>
<StateContext.Provider value={state}>
<div className={cssStyle.container}>
<div className={cssStyle.lane}>
<BlockTopLeft />
<BlockTopRight />
</div>
<div className={cssStyle.lane + " " + (state.state === "music-bot" ? cssStyle.musicBotInfo : "")}>
<BlockBottomLeft />
<BlockBottomRight />
</div>
</div>
</StateContext.Provider>
</EventsContext.Provider>
);
}

View File

@ -1,10 +1,10 @@
import {Registry, RegistryMap} from "tc-shared/events";
import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI";
import {ConversationPanel} from "./AbstractConversationRenderer";
import * as React from "react";
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
class PopoutConversationUI extends AbstractModal {
class PopoutConversationRenderer extends AbstractModal {
private readonly events: Registry<ConversationUIEvents>;
private readonly userData: any;
@ -28,4 +28,4 @@ class PopoutConversationUI extends AbstractModal {
}
}
export = PopoutConversationUI;
export = PopoutConversationRenderer;

View File

@ -0,0 +1,162 @@
import {ConnectionHandler} from "../../../ConnectionHandler";
import {EventHandler} from "../../../events";
import {
PrivateConversationInfo,
PrivateConversationUIEvents
} from "../../../ui/frames/side/PrivateConversationDefinitions";
import * as ReactDOM from "react-dom";
import * as React from "react";
import {PrivateConversationsPanel} from "./PrivateConversationRenderer";
import {
ConversationUIEvents
} from "../../../ui/frames/side/ConversationDefinitions";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {AbstractConversationController} from "./AbstractConversationController";
import { tr } from "tc-shared/i18n/localize";
import {
PrivateConversation,
PrivateConversationEvents,
PrivateConversationManager,
PrivateConversationManagerEvents
} from "tc-shared/conversations/PrivateConversationManager";
export type OutOfViewClient = {
nickname: string,
clientId: number,
uniqueId: string
}
function generateConversationUiInfo(conversation: PrivateConversation) : PrivateConversationInfo {
const lastMessage = conversation.getPresentMessages().last();
const lastClientInfo = conversation.getLastClientInfo();
return {
nickname: lastClientInfo.nickname,
uniqueId: lastClientInfo.uniqueId,
clientId: lastClientInfo.clientId,
chatId: conversation.clientUniqueId,
lastMessage: lastMessage ? lastMessage.timestamp : 0,
unreadMessages: conversation.isUnread()
};
}
export class PrivateConversationController extends AbstractConversationController<
PrivateConversationUIEvents,
PrivateConversationManager,
PrivateConversationManagerEvents,
PrivateConversation,
PrivateConversationEvents
> {
public readonly htmlTag: HTMLDivElement;
public readonly connection: ConnectionHandler;
private listenerConversation: {[key: string]:(() => void)[]};
constructor(connection: ConnectionHandler) {
super(connection.getPrivateConversations());
this.connection = connection;
this.listenerConversation = {};
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "row";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
this.uiEvents.register_handler(this, true);
this.uiEvents.enableDebug("private-conversations");
ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible)
return;
this.handlePanelShow();
}));
this.listenerManager.push(this.conversationManager.events.on("notify_conversation_created", event => {
const conversation = event.conversation;
const events = this.listenerConversation[conversation.getChatId()] = [];
events.push(conversation.events.on("notify_partner_changed", event => {
this.uiEvents.fire_react("notify_partner_changed", { chatId: conversation.getChatId(), clientId: event.clientId, name: event.name });
}));
events.push(conversation.events.on("notify_partner_name_changed", event => {
this.uiEvents.fire_react("notify_partner_name_changed", { chatId: conversation.getChatId(), name: event.name });
}));
events.push(conversation.events.on("notify_partner_typing", () => {
this.uiEvents.fire_react("notify_partner_typing", { chatId: conversation.getChatId() });
}));
events.push(conversation.events.on("notify_unread_state_changed", event => {
this.uiEvents.fire_react("notify_unread_state_changed", { chatId: conversation.getChatId(), unread: event.unread });
}));
this.reportConversationList();
}));
this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => {
this.listenerConversation[event.conversation.getChatId()]?.forEach(callback => callback());
delete this.listenerConversation[event.conversation.getChatId()];
this.reportConversationList();
}));
this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", () => this.reportConversationList()));
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
this.uiEvents.unregister_handler(this);
super.destroy();
}
focusInput() {
this.uiEvents.fire_react("action_focus_chat");
}
private reportConversationList() {
this.uiEvents.fire_react("notify_private_conversations", {
conversations: this.conversationManager.getConversations().map(generateConversationUiInfo),
selected: this.conversationManager.getSelectedConversation()?.clientUniqueId || "unselected"
});
}
@EventHandler<PrivateConversationUIEvents>("query_private_conversations")
private handleQueryPrivateConversations() {
this.reportConversationList();
}
@EventHandler<PrivateConversationUIEvents>("action_close_chat")
private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) {
const conversation = this.conversationManager.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId);
return;
}
this.conversationManager.closeConversation(conversation);
}
@EventHandler<PrivateConversationUIEvents>("notify_partner_typing")
private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_partner_typing"]) {
/* TODO, set active chat? MH 9/12/20: What?? */
}
@EventHandler<ConversationUIEvents>("action_self_typing")
protected handleActionSelfTyping1(_event: ConversationUIEvents["action_self_typing"]) {
const conversation = this.getCurrentConversation();
if(!conversation) {
return;
}
const clientId = conversation.currentClientId();
if(!clientId) {
return;
}
this.connection.serverConnection.send_command("clientchatcomposing", { clid: clientId }).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to send chat composing to server for chat %d: %o"), clientId, error);
});
}
}

View File

@ -15,11 +15,11 @@ export interface PrivateConversationUIEvents extends ConversationUIEvents {
action_close_chat: { chatId: string },
query_private_conversations: {},
notify_private_conversations: {
conversations: PrivateConversationInfo[],
selected: string
}
},
notify_partner_changed: {
chatId: string,
clientId: number,
@ -28,5 +28,9 @@ export interface PrivateConversationUIEvents extends ConversationUIEvents {
notify_partner_name_changed: {
chatId: string,
name: string
},
notify_unread_state_changed: {
chatId: string,
unread: boolean
}
}

View File

@ -6,7 +6,7 @@ import {
} from "tc-shared/ui/frames/side/PrivateConversationDefinitions";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider";
import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI";
import {ConversationPanel} from "./AbstractConversationRenderer";
import {useContext, useEffect, useState} from "react";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
@ -14,7 +14,7 @@ import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars";
const cssStyle = require("./PrivateConversationUI.scss");
const cssStyle = require("./PrivateConversationRenderer.scss");
const kTypingTimeout = 5000;
const HandlerIdContext = React.createContext<string>(undefined);
@ -119,22 +119,17 @@ const ConversationEntryUnreadMarker = React.memo((props: { chatId: string, initi
const events = useContext(EventContext);
const [ unread, setUnread ] = useState(props.initialUnread);
events.reactUse("notify_unread_timestamp_changed", event => {
if(event.chatId !== props.chatId)
events.reactUse("notify_unread_state_changed", event => {
if(event.chatId !== props.chatId) {
return;
}
setUnread(event.timestamp !== undefined);
setUnread(event.unread);
});
events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId || !event.triggerUnread)
return;
setUnread(true);
});
if(!unread)
if(!unread) {
return null;
}
return <div key={"unread-marker"} className={cssStyle.unread} />;
});

View File

@ -141,8 +141,9 @@ export class MusicInfo {
this._container_playlist = this._html_tag.find(".container-playlist");
this._html_tag.find(".button-close").on('click', () => {
if(this.previous_frame_content === FrameContent.CLIENT_INFO)
if(this.previous_frame_content === FrameContent.CLIENT_INFO) {
this.previous_frame_content = FrameContent.NONE;
}
this.handle.set_content(this.previous_frame_content);
});

View File

@ -1,5 +1,5 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
@import "../../../css/static/mixin.scss";
@import "../../../css/static/properties.scss";
html:root {
--chatbox-emoji-hover-background: #454545;

View File

@ -23,7 +23,7 @@ registerHandler({
registerHandler({
name: "conversation",
loadClass: async () => await import("tc-shared/ui/frames/side/PopoutConversationUI")
loadClass: async () => await import("../../frames/side/PopoutConversationRenderer")
});

View File

@ -1,6 +1,6 @@
import * as React from "react";
import {parseMessageWithArguments} from "tc-shared/ui/frames/chat";
import {cloneElement, ReactNode} from "react";
import {cloneElement} from "react";
let instances = [];
export class Translatable extends React.Component<{
@ -51,7 +51,7 @@ export class Translatable extends React.Component<{
}
let renderBrElementIndex = 0;
export type VariadicTranslatableChild = React.ReactElement | string;
export type VariadicTranslatableChild = React.ReactElement | string | number;
export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, children?: VariadicTranslatableChild[] | VariadicTranslatableChild }) => {
const args = Array.isArray(props.children) ? props.children : [props.children];
const argsUseCount = [...new Array(args.length)].map(() => 0);
@ -64,7 +64,7 @@ export const VariadicTranslatable = (props: { text: string, __cacheKey?: string,
if(typeof e === "string") {
return e.split("\n").reduce((result, element) => {
if(result.length > 0) {
result.push(<br key={++this.renderBrElementIndex}/>);
result.push(<br key={++renderBrElementIndex}/>);
}
result.push(element);
return result;
@ -73,7 +73,7 @@ export const VariadicTranslatable = (props: { text: string, __cacheKey?: string,
let element = args[e];
if(argsUseCount[e]) {
if(typeof element === "string") {
if(typeof element === "string" || typeof element === "number") {
/* do nothing */
} else {
element = cloneElement(element);

View File

@ -134,7 +134,7 @@ class ChannelTreeController {
}
private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) {
if(event.new_state !== ConnectionState.CONNECTED) {
if(event.newState !== ConnectionState.CONNECTED) {
this.channelTreeInitialized = false;
this.sendChannelTreeEntries();
}

View File

@ -1,7 +1,7 @@
import {
AbstractServerConnection,
CommandOptionDefaults,
CommandOptions,
CommandOptions, ConnectionPing,
ConnectionStateListener,
ConnectionStatistics,
} from "tc-shared/connection/ConnectionBase";
@ -378,6 +378,7 @@ export class ServerConnection extends AbstractServerConnection {
this.pingStatistics.lastResponseTimestamp = 'now' in performance ? performance.now() : Date.now();
this.pingStatistics.currentJsValue = this.pingStatistics.lastResponseTimestamp - this.pingStatistics.lastRequestTimestamp;
this.pingStatistics.currentNativeValue = parseInt(json["ping_native"]) / 1000; /* we're getting it in microseconds and not milliseconds */
this.events.fire("notify_ping_updated", { newPing: this.ping() });
//log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3));
}
} else if(json["type"] === "WebRTC") {
@ -526,10 +527,11 @@ export class ServerConnection extends AbstractServerConnection {
}
}
ping(): { native: number; javascript?: number } {
ping(): ConnectionPing {
return {
javascript: this.pingStatistics.currentJsValue,
native: this.pingStatistics.currentNativeValue
/* if the native value is zero that means we don't have any */
native: this.pingStatistics.currentNativeValue === 0 ? this.pingStatistics.currentJsValue : this.pingStatistics.currentNativeValue
};
}