This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/plugins/chat/assets/javascripts/discourse/services/chat.js
Jarek Radosz 19214aff18
DEV: Clean up all message bus subscriptions (#19268)
1. "What Goes Up Must Come Down" – if you subscribe to message bus, make sure you also unsubscribe
2. When you unsubscribe - remove only your subscription, not **all** subscriptions on given channel

Attempt #2. The first attempt tried to extend a core `@bound` method in new-user-narrative plugin which did not work. I reworked that plugin in the meantime. This new PR also cleans up message bus subscriptions in now core-merged chat plugin.
2022-12-12 16:32:25 +01:00

1056 lines
30 KiB
JavaScript

import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
import deprecated from "discourse-common/lib/deprecated";
import userSearch from "discourse/lib/user-search";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service";
import Site from "discourse/models/site";
import { ajax } from "discourse/lib/ajax";
import { A } from "@ember/array";
import { generateCookFunction } from "discourse/lib/text";
import { cancel, next } from "@ember/runloop";
import { and } from "@ember/object/computed";
import { Promise } from "rsvp";
import ChatChannel, {
CHANNEL_STATUSES,
CHATABLE_TYPES,
} from "discourse/plugins/chat/discourse/models/chat-channel";
import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
import discourseDebounce from "discourse-common/lib/debounce";
import EmberObject, { computed } from "@ember/object";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import discourseLater from "discourse-common/lib/later";
import userPresent from "discourse/lib/user-presence";
import { bind } from "discourse-common/utils/decorators";
export const LIST_VIEW = "list_view";
export const CHAT_VIEW = "chat_view";
export const DRAFT_CHANNEL_VIEW = "draft_channel_view";
const CHAT_ONLINE_OPTIONS = {
userUnseenTime: 300000, // 5 minutes seconds with no interaction
browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes
};
const READ_INTERVAL = 1000;
export default class Chat extends Service {
@service appEvents;
@service chatNotificationManager;
@service chatStateManager;
@service presence;
@service router;
@service site;
activeChannel = null;
allChannels = null;
cook = null;
directMessageChannels = null;
hasFetchedChannels = false;
hasUnreadMessages = false;
idToTitleMap = null;
lastUserTrackingMessageId = null;
messageId = null;
presenceChannel = null;
publicChannels = null;
sidebarActive = false;
unreadUrgentCount = null;
directMessagesLimit = 20;
isNetworkUnreliable = false;
@and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat;
_fetchingChannels = null;
_onNewMentionsCallbacks = new Map();
_onNewMessagesCallbacks = new Map();
@computed("currentUser.staff", "currentUser.groups.[]")
get userCanDirectMessage() {
if (!this.currentUser) {
return false;
}
return (
this.currentUser.staff ||
this.currentUser.isInAnyGroups(
(this.siteSettings.direct_message_enabled_groups || "11") // trust level 1 auto group
.split("|")
.map((groupId) => parseInt(groupId, 10))
)
);
}
init() {
super.init(...arguments);
if (this.userCanChat) {
this.set("allChannels", []);
this.presenceChannel = this.presence.getChannel("/chat/online");
this.draftStore = {};
if (this.currentUser.chat_drafts) {
this.currentUser.chat_drafts.forEach((draft) => {
this.draftStore[draft.channel_id] = JSON.parse(draft.data);
});
}
}
}
markNetworkAsUnreliable() {
cancel(this._networkCheckHandler);
this.set("isNetworkUnreliable", true);
this._networkCheckHandler = discourseLater(() => {
if (this.isDestroyed || this.isDestroying) {
return;
}
this.markNetworkAsReliable();
}, 30000);
}
markNetworkAsReliable() {
cancel(this._networkCheckHandler);
this.set("isNetworkUnreliable", false);
}
setupWithPreloadedChannels(channels) {
this.currentUser.set("chat_channel_tracking_state", {});
this._processChannels(channels || {});
this.subscribeToChannelMessageBus();
this.userChatChannelTrackingStateChanged();
this.appEvents.trigger("chat:refresh-channels");
}
setupWithoutPreloadedChannels() {
this.getChannels().then(() => {
this.subscribeToChannelMessageBus();
});
}
subscribeToChannelMessageBus() {
this._subscribeToNewChannelUpdates();
this._subscribeToUserTrackingChannel();
this._subscribeToChannelEdits();
this._subscribeToChannelMetadata();
this._subscribeToChannelStatusChange();
}
willDestroy() {
super.willDestroy(...arguments);
if (this.userCanChat) {
this.set("allChannels", null);
this._unsubscribeFromNewDmChannelUpdates();
this._unsubscribeFromUserTrackingChannel();
this._unsubscribeFromChannelEdits();
this._unsubscribeFromChannelMetadata();
this._unsubscribeFromChannelStatusChange();
this._unsubscribeFromAllChatChannels();
}
}
setActiveChannel(channel) {
this.set("activeChannel", channel);
}
loadCookFunction(categories) {
if (this.cook) {
return Promise.resolve(this.cook);
}
const markdownOptions = {
featuresOverride: Site.currentProp(
"markdown_additional_options.chat.limited_pretty_text_features"
),
markdownItRules: Site.currentProp(
"markdown_additional_options.chat.limited_pretty_text_markdown_rules"
),
hashtagTypesInPriorityOrder:
this.site.hashtag_configurations["chat-composer"],
hashtagIcons: this.site.hashtag_icons,
};
return generateCookFunction(markdownOptions).then((cookFunction) => {
return this.set("cook", (raw) => {
return simpleCategoryHashMentionTransform(
cookFunction(raw),
categories
);
});
});
}
updatePresence() {
next(() => {
if (this.isDestroyed || this.isDestroying) {
return;
}
if (
this.chatStateManager.isFullPageActive ||
this.chatStateManager.isDrawerActive
) {
this.presenceChannel.enter({ activeOptions: CHAT_ONLINE_OPTIONS });
} else {
this.presenceChannel.leave();
}
});
}
getDocumentTitleCount() {
return this.chatNotificationManager.shouldCountChatInDocTitle()
? this.unreadUrgentCount
: 0;
}
_channelObject() {
return {
publicChannels: this.publicChannels,
directMessageChannels: this.directMessageChannels,
};
}
truncateDirectMessageChannels(channels) {
return channels.slice(0, this.directMessagesLimit);
}
async getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
let sortedChannels = this.allChannels.sort((a, b) => {
return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at)
? -1
: 1;
});
const trimmedFilter = filter.trim();
const lowerCasedFilter = filter.toLowerCase();
const { activeChannel } = this;
return sortedChannels.filter((channel) => {
if (
opts.excludeActiveChannel &&
activeChannel &&
activeChannel.id === channel.id
) {
return false;
}
if (!trimmedFilter.length) {
return true;
}
if (channel.isDirectMessageChannel) {
let userFound = false;
channel.chatable.users.forEach((user) => {
if (
user.username.toLowerCase().includes(lowerCasedFilter) ||
user.name?.toLowerCase().includes(lowerCasedFilter)
) {
return (userFound = true);
}
});
return userFound;
} else {
return channel.title.toLowerCase().includes(lowerCasedFilter);
}
});
}
switchChannelUpOrDown(direction) {
const { activeChannel } = this;
if (!activeChannel) {
return; // Chat isn't open. Return and do nothing!
}
let currentList, otherList;
if (activeChannel.isDirectMessageChannel) {
currentList = this.truncateDirectMessageChannels(
this.directMessageChannels
);
otherList = this.publicChannels;
} else {
currentList = this.publicChannels;
otherList = this.truncateDirectMessageChannels(
this.directMessageChannels
);
}
const directionUp = direction === "up";
const currentChannelIndex = currentList.findIndex(
(c) => c.id === activeChannel.id
);
let nextChannelInSameList =
currentList[currentChannelIndex + (directionUp ? -1 : 1)];
if (nextChannelInSameList) {
// You're navigating in the same list of channels, just use index +- 1
return this.openChannel(nextChannelInSameList);
}
// You need to go to the next list of channels, if it exists.
const nextList = otherList.length ? otherList : currentList;
const nextChannel = directionUp
? nextList[nextList.length - 1]
: nextList[0];
if (nextChannel.id !== activeChannel.id) {
return this.openChannel(nextChannel);
}
}
getChannels() {
return new Promise((resolve) => {
if (this.hasFetchedChannels) {
return resolve(this._channelObject());
}
if (!this._fetchingChannels) {
this._fetchingChannels = this._refreshChannels();
}
this._fetchingChannels
.then(() => resolve(this._channelObject()))
.finally(() => (this._fetchingChannels = null));
});
}
forceRefreshChannels() {
this.set("hasFetchedChannels", false);
this._unsubscribeFromAllChatChannels();
return this.getChannels();
}
refreshTrackingState() {
if (!this.currentUser) {
return;
}
return ajax("/chat/chat_channels.json")
.then((response) => {
this.currentUser.set("chat_channel_tracking_state", {});
(response.direct_message_channels || []).forEach((channel) => {
this._updateUserTrackingState(channel);
});
(response.public_channels || []).forEach((channel) => {
this._updateUserTrackingState(channel);
});
})
.finally(() => {
this.userChatChannelTrackingStateChanged();
});
}
_refreshChannels() {
return new Promise((resolve) => {
this.setProperties({
loading: true,
allChannels: [],
});
this.currentUser.set("chat_channel_tracking_state", {});
ajax("/chat/chat_channels.json").then((channels) => {
this._processChannels(channels);
this.userChatChannelTrackingStateChanged();
this.appEvents.trigger("chat:refresh-channels");
resolve(this._channelObject());
});
});
}
_processChannels(channels) {
// Must be set first because `processChannels` relies on this data.
this.set("messageBusLastIds", channels.message_bus_last_ids);
this.setProperties({
publicChannels: A(
this.sortPublicChannels(
(channels.public_channels || []).map((channel) =>
this.processChannel(channel)
)
)
),
directMessageChannels: A(
this.sortDirectMessageChannels(
(channels.direct_message_channels || []).map((channel) =>
this.processChannel(channel)
)
)
),
hasFetchedChannels: true,
loading: false,
});
const idToTitleMap = {};
this.allChannels.forEach((c) => {
idToTitleMap[c.id] = c.title;
});
this.set("idToTitleMap", idToTitleMap);
this.presenceChannel.subscribe(channels.global_presence_channel_state);
}
reSortDirectMessageChannels() {
this.set(
"directMessageChannels",
this.sortDirectMessageChannels(this.directMessageChannels)
);
}
async getChannelBy(key, value) {
return this.getChannels().then(() => {
if (!isNaN(value)) {
value = parseInt(value, 10);
}
return (this.allChannels || []).findBy(key, value);
});
}
searchPossibleDirectMessageUsers(options) {
// TODO: implement a chat specific user search function
return userSearch(options);
}
getIdealFirstChannelId() {
// When user opens chat we need to give them the 'best' channel when they enter.
//
// Look for public channels with mentions. If one exists, enter that.
// Next best is a DM channel with unread messages.
// Next best is a public channel with unread messages.
// Then we fall back to the chat_default_channel_id site setting
// if that is present and in the list of channels the user can access.
// If none of these options exist, then we get the first public channel,
// or failing that the first DM channel.
return this.getChannels().then(() => {
// Defined in order of significance.
let publicChannelWithMention,
dmChannelWithUnread,
publicChannelWithUnread,
publicChannel,
dmChannel,
defaultChannel;
for (const [channel, state] of Object.entries(
this.currentUser.chat_channel_tracking_state
)) {
if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) {
if (!dmChannelWithUnread && state.unread_count > 0) {
dmChannelWithUnread = channel;
} else if (!dmChannel) {
dmChannel = channel;
}
} else {
if (state.unread_mentions > 0) {
publicChannelWithMention = channel;
break; // <- We have a public channel with a mention. Break and return this.
} else if (!publicChannelWithUnread && state.unread_count > 0) {
publicChannelWithUnread = channel;
} else if (
!defaultChannel &&
parseInt(this.siteSettings.chat_default_channel_id || 0, 10) ===
parseInt(channel, 10)
) {
defaultChannel = channel;
} else if (!publicChannel) {
publicChannel = channel;
}
}
}
return (
publicChannelWithMention ||
dmChannelWithUnread ||
publicChannelWithUnread ||
defaultChannel ||
publicChannel ||
dmChannel
);
});
}
sortPublicChannels(channels) {
return channels.sort((a, b) => a.title.localeCompare(b.title));
}
sortDirectMessageChannels(channels) {
return channels.sort((a, b) => {
const unreadCountA =
this.currentUser.chat_channel_tracking_state[a.id]?.unread_count || 0;
const unreadCountB =
this.currentUser.chat_channel_tracking_state[b.id]?.unread_count || 0;
if (unreadCountA === unreadCountB) {
return new Date(a.last_message_sent_at) >
new Date(b.last_message_sent_at)
? -1
: 1;
} else {
return unreadCountA > unreadCountB ? -1 : 1;
}
});
}
getIdealFirstChannelIdAndTitle() {
return this.getIdealFirstChannelId().then((channelId) => {
if (!channelId) {
return;
}
return {
id: channelId,
title: this.idToTitleMap[channelId],
};
});
}
async openChannelAtMessage(channelId, messageId = null) {
let channel = await this.getChannelBy("id", channelId);
if (channel) {
return this._openFoundChannelAtMessage(channel, messageId);
}
return ajax(`/chat/chat_channels/${channelId}`).then((response) => {
const queryParams = messageId ? { messageId } : {};
return this.router.transitionTo(
"chat.channel",
response.id,
slugifyChannel(response),
{ queryParams }
);
});
}
async openChannel(channel) {
return this._openFoundChannelAtMessage(channel);
}
async _openFoundChannelAtMessage(channel, messageId = null) {
if (
this.router.currentRouteName === "chat.channel.index" &&
this.activeChannel?.id === channel.id
) {
this.setActiveChannel(channel);
this._fireOpenMessageAppEvent(messageId);
return Promise.resolve();
}
this.setActiveChannel(channel);
if (
this.chatStateManager.isFullPageActive ||
this.site.mobileView ||
this.chatStateManager.isFullPagePreferred
) {
const queryParams = messageId ? { messageId } : {};
return this.router.transitionTo(
"chat.channel",
channel.id,
slugifyChannel(channel),
{ queryParams }
);
} else {
this._fireOpenFloatAppEvent(channel, messageId);
return Promise.resolve();
}
}
_fireOpenFloatAppEvent(channel, messageId = null) {
messageId
? this.appEvents.trigger(
"chat:open-channel-at-message",
channel,
messageId
)
: this.appEvents.trigger("chat:open-channel", channel);
}
_fireOpenMessageAppEvent(messageId) {
this.appEvents.trigger("chat-live-pane:highlight-message", messageId);
}
async startTrackingChannel(channel) {
if (!channel.current_user_membership.following) {
return;
}
let existingChannel = await this.getChannelBy("id", channel.id);
if (existingChannel) {
return existingChannel; // User is already tracking this channel. return!
}
const existingChannels = channel.isDirectMessageChannel
? this.directMessageChannels
: this.publicChannels;
// this check shouldn't be needed given the previous check to existingChannel
// this is a safety net, to ensure we never track duplicated channels
existingChannel = existingChannels.findBy("id", channel.id);
if (existingChannel) {
return existingChannel;
}
const newChannel = this.processChannel(channel);
existingChannels.pushObject(newChannel);
this.currentUser.chat_channel_tracking_state[channel.id] =
EmberObject.create({
unread_count: 1,
unread_mentions: 0,
chatable_type: channel.chatable_type,
});
this.userChatChannelTrackingStateChanged();
if (channel.isDirectMessageChannel) {
this.reSortDirectMessageChannels();
}
if (channel.isPublicChannel) {
this.set("publicChannels", this.sortPublicChannels(this.publicChannels));
}
this.appEvents.trigger("chat:refresh-channels");
return newChannel;
}
async stopTrackingChannel(channel) {
return this.getChannelBy("id", channel.id).then((existingChannel) => {
if (existingChannel) {
return this.forceRefreshChannels();
}
});
}
_subscribeToChannelMetadata() {
this.messageBus.subscribe(
"/chat/channel-metadata",
this._onChannelMetadata,
this.messageBusLastIds.channel_metadata
);
}
_subscribeToChannelEdits() {
this.messageBus.subscribe(
"/chat/channel-edits",
this._onChannelEdits,
this.messageBusLastIds.channel_edits
);
}
_subscribeToChannelStatusChange() {
this.messageBus.subscribe("/chat/channel-status", this._onChannelStatus);
}
_unsubscribeFromChannelStatusChange() {
this.messageBus.unsubscribe("/chat/channel-status", this._onChannelStatus);
}
_unsubscribeFromChannelEdits() {
this.messageBus.unsubscribe("/chat/channel-edits", this._onChannelEdits);
}
_unsubscribeFromChannelMetadata() {
this.messageBus.unsubscribe(
"/chat/channel-metadata",
this._onChannelMetadata
);
}
_subscribeToNewChannelUpdates() {
this.messageBus.subscribe(
"/chat/new-channel",
this._onNewChannel,
this.messageBusLastIds.new_channel
);
}
_unsubscribeFromNewDmChannelUpdates() {
this.messageBus.unsubscribe("/chat/new-channel", this._onNewChannel);
}
_subscribeToSingleUpdateChannel(channel) {
if (channel.current_user_membership.muted) {
return;
}
// We do this first so we don't multi-subscribe to mention + messages
// messageBus channels for this chat channel, since _subscribeToSingleUpdateChannel
// is called from multiple places.
this._unsubscribeFromChatChannel(channel);
if (!channel.isDirectMessageChannel) {
this._subscribeToMentionChannel(channel);
}
this._subscribeToNewMessagesChannel(channel);
}
_subscribeToMentionChannel(channel) {
const onNewMentions = () => {
const trackingState =
this.currentUser.chat_channel_tracking_state[channel.id];
if (trackingState) {
const count = (trackingState.unread_mentions || 0) + 1;
trackingState.set("unread_mentions", count);
this.userChatChannelTrackingStateChanged();
}
};
this._onNewMentionsCallbacks.set(channel.id, onNewMentions);
this.messageBus.subscribe(
`/chat/${channel.id}/new-mentions`,
onNewMentions,
channel.message_bus_last_ids.new_mentions
);
}
_subscribeToNewMessagesChannel(channel) {
const onNewMessages = (busData) => {
const trackingState =
this.currentUser.chat_channel_tracking_state[channel.id];
if (busData.user_id === this.currentUser.id) {
// User sent message, update tracking state to no unread
trackingState.set("chat_message_id", busData.message_id);
} else {
// Ignored user sent message, update tracking state to no unread
if (this.currentUser.ignored_users.includes(busData.username)) {
trackingState.set("chat_message_id", busData.message_id);
} else {
// Message from other user. Increment trackings state
if (busData.message_id > (trackingState.chat_message_id || 0)) {
trackingState.set("unread_count", trackingState.unread_count + 1);
}
}
}
this.userChatChannelTrackingStateChanged();
channel.set("last_message_sent_at", new Date());
const directMessageChannel = (this.directMessageChannels || []).findBy(
"id",
parseInt(channel.id, 10)
);
if (directMessageChannel) {
this.reSortDirectMessageChannels();
}
};
this._onNewMessagesCallbacks.set(channel.id, onNewMessages);
this.messageBus.subscribe(
`/chat/${channel.id}/new-messages`,
onNewMessages,
channel.message_bus_last_ids.new_messages
);
}
@bind
_onChannelMetadata(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
memberships_count: busData.memberships_count,
});
this.appEvents.trigger("chat:refresh-channel-members");
}
});
}
@bind
_onChannelEdits(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
title: busData.name,
description: busData.description,
});
}
});
}
@bind
_onChannelStatus(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (!channel) {
return;
}
channel.set("status", busData.status);
// it is not possible for the user to set their last read message id
// if the channel has been archived, because all the messages have
// been deleted. we don't want them seeing the blue dot anymore so
// just completely reset the unreads
if (busData.status === CHANNEL_STATUSES.archived) {
this.currentUser.chat_channel_tracking_state[channel.id] = {
unread_count: 0,
unread_mentions: 0,
chatable_type: channel.chatable_type,
};
this.userChatChannelTrackingStateChanged();
}
this.appEvents.trigger("chat:refresh-channel", channel.id);
}, this.messageBusLastIds.channel_status);
}
@bind
_onNewChannel(busData) {
this.startTrackingChannel(ChatChannel.create(busData.chat_channel));
}
async followChannel(channel) {
return ChatApi.followChatChannel(channel).then(() => {
this.startTrackingChannel(channel);
this._subscribeToSingleUpdateChannel(channel);
});
}
async unfollowChannel(channel) {
return ChatApi.unfollowChatChannel(channel).then(() => {
this._unsubscribeFromChatChannel(channel);
this.stopTrackingChannel(channel);
if (channel === this.activeChannel && channel.isDirectMessageChannel) {
this.router.transitionTo("chat");
}
});
}
_unsubscribeFromAllChatChannels() {
(this.allChannels || []).forEach((channel) => {
this._unsubscribeFromChatChannel(channel);
});
}
_unsubscribeFromChatChannel(channel) {
this.messageBus.unsubscribe("/chat/*", this._onNewMessagesCallbacks);
if (!channel.isDirectMessageChannel) {
this.messageBus.unsubscribe("/chat/*", this._onNewMentionsCallbacks);
}
}
_subscribeToUserTrackingChannel() {
this.messageBus.subscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingState,
this.messageBusLastIds.user_tracking_state
);
}
_unsubscribeFromUserTrackingChannel() {
this.messageBus.unsubscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingState
);
}
@bind
_onUserTrackingState(busData, _, messageId) {
const lastId = this.lastUserTrackingMessageId;
// we don't want this state to go backwards, only catch
// up if messages from messagebus were missed
if (!lastId || messageId > lastId) {
this.lastUserTrackingMessageId = messageId;
}
// we are too far out of sync, we should resync everything.
// this will trigger a route transition and blur the chat input
if (lastId && messageId > lastId + 1) {
return this.forceRefreshChannels();
}
const trackingState =
this.currentUser.chat_channel_tracking_state[busData.chat_channel_id];
if (trackingState) {
trackingState.set("chat_message_id", busData.chat_message_id);
trackingState.set("unread_count", 0);
trackingState.set("unread_mentions", 0);
this.userChatChannelTrackingStateChanged();
}
}
resetTrackingStateForChannel(channelId) {
const trackingState =
this.currentUser.chat_channel_tracking_state[channelId];
if (trackingState) {
trackingState.set("unread_count", 0);
this.userChatChannelTrackingStateChanged();
}
}
userChatChannelTrackingStateChanged() {
this._recalculateUnreadMessages();
this.appEvents.trigger("chat:user-tracking-state-changed");
}
_recalculateUnreadMessages() {
let unreadPublicCount = 0;
let unreadUrgentCount = 0;
let headerNeedsRerender = false;
Object.values(this.currentUser.chat_channel_tracking_state).forEach(
(state) => {
if (state.muted) {
return;
}
if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) {
unreadUrgentCount += state.unread_count || 0;
} else {
unreadUrgentCount += state.unread_mentions || 0;
unreadPublicCount += state.unread_count || 0;
}
}
);
let hasUnreadPublic = unreadPublicCount > 0;
if (hasUnreadPublic !== this.hasUnreadMessages) {
headerNeedsRerender = true;
this.set("hasUnreadMessages", hasUnreadPublic);
}
if (unreadUrgentCount !== this.unreadUrgentCount) {
headerNeedsRerender = true;
this.set("unreadUrgentCount", unreadUrgentCount);
}
this.currentUser.notifyPropertyChange("chat_channel_tracking_state");
if (headerNeedsRerender) {
this.appEvents.trigger("chat:rerender-header");
this.appEvents.trigger("notifications:changed");
}
}
processChannel(channel) {
channel = ChatChannel.create(channel);
this._subscribeToSingleUpdateChannel(channel);
this._updateUserTrackingState(channel);
this.allChannels.push(channel);
return channel;
}
_updateUserTrackingState(channel) {
this.currentUser.chat_channel_tracking_state[channel.id] =
EmberObject.create({
chatable_type: channel.chatable_type,
muted: channel.current_user_membership.muted,
unread_count: channel.current_user_membership.unread_count,
unread_mentions: channel.current_user_membership.unread_mentions,
chat_message_id: channel.current_user_membership.last_read_message_id,
});
}
upsertDmChannelForUser(channel, user) {
const usernames = (channel.chatable.users || [])
.mapBy("username")
.concat(user.username)
.uniq();
return this.upsertDmChannelForUsernames(usernames);
}
// @param {array} usernames - The usernames to create or fetch the direct message
// channel for. The current user will automatically be included in the channel
// when it is created.
upsertDmChannelForUsernames(usernames) {
return ajax("/chat/direct_messages/create.json", {
method: "POST",
data: { usernames: usernames.uniq() },
})
.then((response) => {
const chatChannel = ChatChannel.create(response.chat_channel);
this.startTrackingChannel(chatChannel);
return chatChannel;
})
.catch(popupAjaxError);
}
// @param {array} usernames - The usernames to fetch the direct message
// channel for. The current user will automatically be included as a
// participant to fetch the channel for.
getDmChannelForUsernames(usernames) {
return ajax("/chat/direct_messages.json", {
data: { usernames: usernames.uniq().join(",") },
});
}
_saveDraft(channelId, draft) {
const data = { chat_channel_id: channelId };
if (draft) {
data.data = JSON.stringify(draft);
}
ajax("/chat/drafts", { type: "POST", data, ignoreUnsent: false })
.then(() => {
this.markNetworkAsReliable();
})
.catch((error) => {
if (!error.jqXHR?.responseJSON?.errors?.length) {
this.markNetworkAsUnreliable();
}
});
}
setDraftForChannel(channel, draft) {
if (
draft &&
(draft.value || draft.uploads.length > 0 || draft.replyToMsg)
) {
this.draftStore[channel.id] = draft;
} else {
delete this.draftStore[channel.id];
draft = null; // _saveDraft will destroy draft
}
discourseDebounce(this, this._saveDraft, channel.id, draft, 2000);
}
getDraftForChannel(channelId) {
return (
this.draftStore[channelId] || {
value: "",
uploads: [],
replyToMsg: null,
}
);
}
updateLastReadMessage() {
discourseDebounce(this, this._queuedReadMessageUpdate, READ_INTERVAL);
}
_queuedReadMessageUpdate() {
const visibleMessages = document.querySelectorAll(
".chat-message-container[data-visible=true]"
);
const channel = this.activeChannel;
if (
!channel?.isFollowing ||
visibleMessages?.length === 0 ||
!userPresent()
) {
return;
}
const latestUnreadMsgId = parseInt(
visibleMessages[visibleMessages.length - 1].dataset.id,
10
);
const hasUnreadMessages = latestUnreadMsgId > channel.lastSendReadMessageId;
if (
!hasUnreadMessages &&
this.currentUser.chat_channel_tracking_state[this.activeChannel.id]
?.unread_count > 0
) {
// Weird state here where the chat_channel_tracking_state is wrong. Need to reset it.
this.resetTrackingStateForChannel(this.activeChannel.id);
}
if (hasUnreadMessages) {
channel.updateLastReadMessage(latestUnreadMsgId);
}
}
addToolbarButton() {
deprecated(
"Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`"
);
}
}