| | 1 | | using Cysharp.Threading.Tasks; |
| | 2 | | using DCL; |
| | 3 | | using DCL.Interface; |
| | 4 | | using System; |
| | 5 | | using System.Collections.Generic; |
| | 6 | | using System.Text.RegularExpressions; |
| | 7 | | using DCL.Chat; |
| | 8 | |
|
| | 9 | | public class ChatHUDController : IDisposable |
| | 10 | | { |
| | 11 | | public const int MAX_CHAT_ENTRIES = 30; |
| | 12 | | private const int TEMPORARILY_MUTE_MINUTES = 10; |
| | 13 | | private const int MAX_CONTINUOUS_MESSAGES = 6; |
| | 14 | | private const int MIN_MILLISECONDS_BETWEEN_MESSAGES = 1500; |
| | 15 | | private const int MAX_HISTORY_ITERATION = 10; |
| | 16 | |
|
| | 17 | | public event Action OnInputFieldSelected; |
| | 18 | | public event Action OnInputFieldDeselected; |
| | 19 | | public event Action<ChatMessage> OnSendMessage; |
| | 20 | | public event Action<string> OnMessageUpdated; |
| | 21 | |
|
| | 22 | | private readonly DataStore dataStore; |
| | 23 | | private readonly IUserProfileBridge userProfileBridge; |
| | 24 | | private readonly bool detectWhisper; |
| | 25 | | private readonly IProfanityFilter profanityFilter; |
| 65 | 26 | | private readonly Regex whisperRegex = new Regex(@"(?i)^\/(whisper|w) (\S+)( *)(.*)"); |
| 65 | 27 | | private readonly Dictionary<string, ulong> temporarilyMutedSenders = new Dictionary<string, ulong>(); |
| 65 | 28 | | private readonly List<ChatEntryModel> spamMessages = new List<ChatEntryModel>(); |
| 65 | 29 | | private readonly List<string> lastMessagesSent = new List<string>(); |
| | 30 | | private int currentHistoryIteration; |
| | 31 | | private IChatHUDComponentView view; |
| | 32 | |
|
| 65 | 33 | | public ChatHUDController(DataStore dataStore, |
| | 34 | | IUserProfileBridge userProfileBridge, |
| | 35 | | bool detectWhisper, |
| | 36 | | IProfanityFilter profanityFilter = null) |
| | 37 | | { |
| 65 | 38 | | this.dataStore = dataStore; |
| 65 | 39 | | this.userProfileBridge = userProfileBridge; |
| 65 | 40 | | this.detectWhisper = detectWhisper; |
| 65 | 41 | | this.profanityFilter = profanityFilter; |
| 65 | 42 | | } |
| | 43 | |
|
| 1 | 44 | | public bool IsInputSelected => view.IsInputFieldSelected; |
| | 45 | |
|
| | 46 | | public void Initialize(IChatHUDComponentView view) |
| | 47 | | { |
| 65 | 48 | | this.view = view; |
| 65 | 49 | | this.view.OnPreviousChatInHistory -= FillInputWithPreviousMessage; |
| 65 | 50 | | this.view.OnPreviousChatInHistory += FillInputWithPreviousMessage; |
| 65 | 51 | | this.view.OnNextChatInHistory -= FillInputWithNextMessage; |
| 65 | 52 | | this.view.OnNextChatInHistory += FillInputWithNextMessage; |
| 65 | 53 | | this.view.OnShowMenu -= ContextMenu_OnShowMenu; |
| 65 | 54 | | this.view.OnShowMenu += ContextMenu_OnShowMenu; |
| 65 | 55 | | this.view.OnInputFieldSelected -= HandleInputFieldSelected; |
| 65 | 56 | | this.view.OnInputFieldSelected += HandleInputFieldSelected; |
| 65 | 57 | | this.view.OnInputFieldDeselected -= HandleInputFieldDeselected; |
| 65 | 58 | | this.view.OnInputFieldDeselected += HandleInputFieldDeselected; |
| 65 | 59 | | this.view.OnSendMessage -= HandleSendMessage; |
| 65 | 60 | | this.view.OnSendMessage += HandleSendMessage; |
| 65 | 61 | | this.view.OnMessageUpdated -= HandleMessageUpdated; |
| 65 | 62 | | this.view.OnMessageUpdated += HandleMessageUpdated; |
| 65 | 63 | | } |
| | 64 | |
|
| | 65 | | public void AddChatMessage(ChatMessage message, bool setScrollPositionToBottom = false, bool spamFiltering = true, b |
| | 66 | | { |
| 6 | 67 | | AddChatMessage(ChatMessageToChatEntry(message), setScrollPositionToBottom, spamFiltering, limitMaxEntries).Forge |
| 6 | 68 | | } |
| | 69 | |
|
| | 70 | | public async UniTask AddChatMessage(ChatEntryModel chatEntryModel, bool setScrollPositionToBottom = false, bool spam |
| | 71 | | { |
| 18 | 72 | | if (IsSpamming(chatEntryModel.senderName) && spamFiltering) return; |
| | 73 | |
|
| 18 | 74 | | chatEntryModel.bodyText = ChatUtils.AddNoParse(chatEntryModel.bodyText); |
| | 75 | |
|
| 18 | 76 | | if (IsProfanityFilteringEnabled() && chatEntryModel.messageType != ChatMessage.Type.PRIVATE) |
| | 77 | | { |
| 10 | 78 | | chatEntryModel.bodyText = await profanityFilter.Filter(chatEntryModel.bodyText); |
| | 79 | |
|
| 10 | 80 | | if (!string.IsNullOrEmpty(chatEntryModel.senderName)) |
| 10 | 81 | | chatEntryModel.senderName = await profanityFilter.Filter(chatEntryModel.senderName); |
| | 82 | |
|
| 10 | 83 | | if (!string.IsNullOrEmpty(chatEntryModel.recipientName)) |
| 2 | 84 | | chatEntryModel.recipientName = await profanityFilter.Filter(chatEntryModel.recipientName); |
| | 85 | | } |
| | 86 | |
|
| 18 | 87 | | await UniTask.SwitchToMainThread(); |
| | 88 | |
|
| 18 | 89 | | view.AddEntry(chatEntryModel, setScrollPositionToBottom); |
| | 90 | |
|
| 18 | 91 | | if (limitMaxEntries && view.EntryCount > MAX_CHAT_ENTRIES) |
| 1 | 92 | | view.RemoveFirstEntry(); |
| | 93 | |
|
| 30 | 94 | | if (string.IsNullOrEmpty(chatEntryModel.senderId)) return; |
| | 95 | |
|
| 6 | 96 | | if (spamFiltering) |
| 6 | 97 | | UpdateSpam(chatEntryModel); |
| 18 | 98 | | } |
| | 99 | |
|
| | 100 | | public void Dispose() |
| | 101 | | { |
| 29 | 102 | | view.OnShowMenu -= ContextMenu_OnShowMenu; |
| 29 | 103 | | view.OnMessageUpdated -= HandleMessageUpdated; |
| 29 | 104 | | view.OnSendMessage -= HandleSendMessage; |
| 29 | 105 | | view.OnInputFieldSelected -= HandleInputFieldSelected; |
| 29 | 106 | | view.OnInputFieldDeselected -= HandleInputFieldDeselected; |
| 29 | 107 | | view.OnPreviousChatInHistory -= FillInputWithPreviousMessage; |
| 29 | 108 | | view.OnNextChatInHistory -= FillInputWithNextMessage; |
| 29 | 109 | | OnSendMessage = null; |
| 29 | 110 | | OnMessageUpdated = null; |
| 29 | 111 | | OnInputFieldSelected = null; |
| 29 | 112 | | view.Dispose(); |
| 29 | 113 | | } |
| | 114 | |
|
| 17 | 115 | | public void ClearAllEntries() => view.ClearAllEntries(); |
| | 116 | |
|
| 6 | 117 | | public void ResetInputField(bool loseFocus = false) => view.ResetInputField(loseFocus); |
| | 118 | |
|
| 16 | 119 | | public void FocusInputField() => view.FocusInputField(); |
| | 120 | |
|
| 4 | 121 | | public void SetInputFieldText(string setInputText) => view.SetInputFieldText(setInputText); |
| | 122 | |
|
| 8 | 123 | | public void UnfocusInputField() => view.UnfocusInputField(); |
| | 124 | |
|
| 12 | 125 | | public void ActivatePreview() => view.ActivatePreview(); |
| | 126 | |
|
| 7 | 127 | | public void DeactivatePreview() => view.DeactivatePreview(); |
| | 128 | |
|
| 5 | 129 | | public void FadeOutMessages() => view.FadeOutMessages(); |
| | 130 | |
|
| | 131 | |
|
| | 132 | | private ChatEntryModel ChatMessageToChatEntry(ChatMessage message) |
| | 133 | | { |
| 6 | 134 | | var model = new ChatEntryModel(); |
| 6 | 135 | | var ownProfile = userProfileBridge.GetOwn(); |
| | 136 | |
|
| 6 | 137 | | model.messageId = message.messageId; |
| 6 | 138 | | model.messageType = message.messageType; |
| 6 | 139 | | model.bodyText = message.body; |
| 6 | 140 | | model.timestamp = message.timestamp; |
| | 141 | |
|
| 6 | 142 | | if (message.recipient != null) |
| | 143 | | { |
| 2 | 144 | | var recipientProfile = userProfileBridge.Get(message.recipient); |
| 2 | 145 | | model.recipientName = recipientProfile != null ? recipientProfile.userName : message.recipient; |
| | 146 | | } |
| | 147 | |
|
| 6 | 148 | | if (message.sender != null) |
| | 149 | | { |
| 6 | 150 | | var senderProfile = userProfileBridge.Get(message.sender); |
| 6 | 151 | | model.senderName = senderProfile != null ? senderProfile.userName : message.sender; |
| 6 | 152 | | model.senderId = message.sender; |
| | 153 | | } |
| | 154 | |
|
| 6 | 155 | | if (message.messageType == ChatMessage.Type.PRIVATE) |
| | 156 | | { |
| 5 | 157 | | if (message.recipient == ownProfile.userId) |
| | 158 | | { |
| 1 | 159 | | model.subType = ChatEntryModel.SubType.RECEIVED; |
| 1 | 160 | | model.otherUserId = message.sender; |
| 1 | 161 | | } |
| 4 | 162 | | else if (message.sender == ownProfile.userId) |
| | 163 | | { |
| 1 | 164 | | model.subType = ChatEntryModel.SubType.SENT; |
| 1 | 165 | | model.otherUserId = message.recipient; |
| 1 | 166 | | } |
| | 167 | | else |
| | 168 | | { |
| 3 | 169 | | model.subType = ChatEntryModel.SubType.NONE; |
| | 170 | | } |
| 3 | 171 | | } |
| 1 | 172 | | else if (message.messageType == ChatMessage.Type.PUBLIC) |
| | 173 | | { |
| 1 | 174 | | model.subType = message.sender == ownProfile.userId |
| | 175 | | ? ChatEntryModel.SubType.SENT |
| | 176 | | : ChatEntryModel.SubType.RECEIVED; |
| | 177 | | } |
| | 178 | |
|
| 6 | 179 | | return model; |
| | 180 | | } |
| | 181 | |
|
| 0 | 182 | | private void ContextMenu_OnShowMenu() => view.OnMessageCancelHover(); |
| | 183 | |
|
| | 184 | | private bool IsProfanityFilteringEnabled() |
| | 185 | | { |
| 18 | 186 | | return dataStore.settings.profanityChatFilteringEnabled.Get() |
| | 187 | | && profanityFilter != null; |
| | 188 | | } |
| | 189 | |
|
| 1 | 190 | | private void HandleMessageUpdated(string obj) => OnMessageUpdated?.Invoke(obj); |
| | 191 | |
|
| | 192 | | private void HandleSendMessage(ChatMessage message) |
| | 193 | | { |
| 15 | 194 | | var ownProfile = userProfileBridge.GetOwn(); |
| 15 | 195 | | message.sender = ownProfile.userId; |
| 15 | 196 | | RegisterMessageHistory(message); |
| 15 | 197 | | currentHistoryIteration = 0; |
| 15 | 198 | | if (IsSpamming(message.sender)) return; |
| 15 | 199 | | if (IsSpamming(ownProfile.userName)) return; |
| 15 | 200 | | ApplyWhisperAttributes(message); |
| 15 | 201 | | OnSendMessage?.Invoke(message); |
| 8 | 202 | | } |
| | 203 | |
|
| | 204 | | private void RegisterMessageHistory(ChatMessage message) |
| | 205 | | { |
| 16 | 206 | | if (string.IsNullOrEmpty(message.body)) return; |
| | 207 | |
|
| 18 | 208 | | lastMessagesSent.RemoveAll(s => s.Equals(message.body)); |
| 14 | 209 | | lastMessagesSent.Insert(0, message.body); |
| | 210 | |
|
| 14 | 211 | | if (lastMessagesSent.Count > MAX_HISTORY_ITERATION) |
| 0 | 212 | | lastMessagesSent.RemoveAt(lastMessagesSent.Count - 1); |
| 14 | 213 | | } |
| | 214 | |
|
| | 215 | | private void ApplyWhisperAttributes(ChatMessage message) |
| | 216 | | { |
| 16 | 217 | | if (!detectWhisper) return; |
| 14 | 218 | | var body = message.body; |
| 15 | 219 | | if (string.IsNullOrWhiteSpace(body)) return; |
| | 220 | |
|
| 13 | 221 | | var match = whisperRegex.Match(body); |
| 24 | 222 | | if (!match.Success) return; |
| | 223 | |
|
| 2 | 224 | | message.messageType = ChatMessage.Type.PRIVATE; |
| 2 | 225 | | message.recipient = match.Groups[2].Value; |
| 2 | 226 | | message.body = match.Groups[4].Value; |
| 2 | 227 | | } |
| | 228 | |
|
| | 229 | | private void HandleInputFieldSelected() |
| | 230 | | { |
| 2 | 231 | | currentHistoryIteration = 0; |
| 2 | 232 | | OnInputFieldSelected?.Invoke(); |
| 2 | 233 | | } |
| | 234 | |
|
| | 235 | | private void HandleInputFieldDeselected() |
| | 236 | | { |
| 2 | 237 | | currentHistoryIteration = 0; |
| 2 | 238 | | OnInputFieldDeselected?.Invoke(); |
| 2 | 239 | | } |
| | 240 | |
|
| | 241 | | private bool IsSpamming(string senderName) |
| | 242 | | { |
| 59 | 243 | | if (string.IsNullOrEmpty(senderName)) return false; |
| | 244 | |
|
| 37 | 245 | | var isSpamming = false; |
| | 246 | |
|
| 74 | 247 | | if (!temporarilyMutedSenders.ContainsKey(senderName)) return false; |
| | 248 | |
|
| 0 | 249 | | var muteTimestamp = CreateBaseDateTime().AddMilliseconds(temporarilyMutedSenders[senderName]).ToLocalTime(); |
| 0 | 250 | | if ((DateTime.Now - muteTimestamp).Minutes < TEMPORARILY_MUTE_MINUTES) |
| 0 | 251 | | isSpamming = true; |
| | 252 | | else |
| 0 | 253 | | temporarilyMutedSenders.Remove(senderName); |
| | 254 | |
|
| 0 | 255 | | return isSpamming; |
| | 256 | | } |
| | 257 | |
|
| | 258 | | private void UpdateSpam(ChatEntryModel model) |
| | 259 | | { |
| 6 | 260 | | if (spamMessages.Count == 0) |
| | 261 | | { |
| 4 | 262 | | spamMessages.Add(model); |
| 4 | 263 | | } |
| 2 | 264 | | else if (spamMessages[spamMessages.Count - 1].senderName == model.senderName) |
| | 265 | | { |
| 2 | 266 | | if (MessagesSentTooFast(spamMessages[spamMessages.Count - 1].timestamp, model.timestamp)) |
| | 267 | | { |
| 2 | 268 | | spamMessages.Add(model); |
| | 269 | |
|
| 2 | 270 | | if (spamMessages.Count == MAX_CONTINUOUS_MESSAGES) |
| | 271 | | { |
| 0 | 272 | | temporarilyMutedSenders.Add(model.senderName, model.timestamp); |
| 0 | 273 | | spamMessages.Clear(); |
| | 274 | | } |
| 0 | 275 | | } |
| | 276 | | else |
| | 277 | | { |
| 0 | 278 | | spamMessages.Clear(); |
| | 279 | | } |
| 0 | 280 | | } |
| | 281 | | else |
| | 282 | | { |
| 0 | 283 | | spamMessages.Clear(); |
| | 284 | | } |
| 2 | 285 | | } |
| | 286 | |
|
| | 287 | | private bool MessagesSentTooFast(ulong oldMessageTimeStamp, ulong newMessageTimeStamp) |
| | 288 | | { |
| 2 | 289 | | DateTime oldDateTime = CreateBaseDateTime().AddMilliseconds(oldMessageTimeStamp).ToLocalTime(); |
| 2 | 290 | | DateTime newDateTime = CreateBaseDateTime().AddMilliseconds(newMessageTimeStamp).ToLocalTime(); |
| 2 | 291 | | return (newDateTime - oldDateTime).TotalMilliseconds < MIN_MILLISECONDS_BETWEEN_MESSAGES; |
| | 292 | | } |
| | 293 | |
|
| | 294 | | private DateTime CreateBaseDateTime() |
| | 295 | | { |
| 4 | 296 | | return new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); |
| | 297 | | } |
| | 298 | |
|
| | 299 | | private void FillInputWithNextMessage() |
| | 300 | | { |
| 5 | 301 | | if (lastMessagesSent.Count == 0) return; |
| 5 | 302 | | view.FocusInputField(); |
| 5 | 303 | | view.SetInputFieldText(lastMessagesSent[currentHistoryIteration]); |
| 5 | 304 | | currentHistoryIteration = (currentHistoryIteration + 1) % lastMessagesSent.Count; |
| 5 | 305 | | } |
| | 306 | |
|
| | 307 | | private void FillInputWithPreviousMessage() |
| | 308 | | { |
| 2 | 309 | | if (lastMessagesSent.Count == 0) |
| | 310 | | { |
| 0 | 311 | | view.ResetInputField(); |
| 0 | 312 | | return; |
| | 313 | | } |
| | 314 | |
|
| 2 | 315 | | currentHistoryIteration--; |
| 2 | 316 | | if (currentHistoryIteration < 0) |
| 1 | 317 | | currentHistoryIteration = lastMessagesSent.Count - 1; |
| | 318 | |
|
| 2 | 319 | | view.FocusInputField(); |
| 2 | 320 | | view.SetInputFieldText(lastMessagesSent[currentHistoryIteration]); |
| 2 | 321 | | } |
| | 322 | | } |