chat.go (15961B)
1 package v1 2 3 import ( 4 "dkforest/pkg/config" 5 "dkforest/pkg/database" 6 dutils "dkforest/pkg/database/utils" 7 "dkforest/pkg/hashset" 8 "dkforest/pkg/managers" 9 "dkforest/pkg/pubsub" 10 "dkforest/pkg/utils" 11 "dkforest/pkg/web/handlers/interceptors/command" 12 "dkforest/pkg/web/handlers/poker" 13 "dkforest/pkg/web/handlers/streamModals" 14 hutils "dkforest/pkg/web/handlers/utils" 15 "dkforest/pkg/web/handlers/utils/stream" 16 "errors" 17 "fmt" 18 "github.com/labstack/echo" 19 "net/http" 20 "strings" 21 "time" 22 ) 23 24 func manualPreload(db *database.DkfDB, msg *database.ChatMessage, room database.ChatRoom) { 25 if msg.GroupID != nil { 26 if msg.Group == nil { 27 group, _ := db.GetRoomGroupByID(msg.RoomID, *msg.GroupID) 28 msg.Group = &group 29 } 30 } 31 if msg.ToUserID != nil { 32 if msg.ToUser == nil { 33 toUser, _ := db.GetUserByID(*msg.ToUserID) 34 msg.ToUser = &toUser 35 } 36 } 37 if msg.User.ID == 0 { 38 msg.User, _ = db.GetUserByID(msg.UserID) 39 } 40 msg.Room = room 41 } 42 43 // Return true if the message passes all the user's filter. 44 // false if the message does not and should be discarded. 45 func applyUserFilters(db *database.DkfDB, authUser database.IUserRenderMessage, msg *database.ChatMessage, 46 pmUserID *database.UserID, pmOnlyQuery database.PmDisplayMode, displayHellbanned, mentionsOnlyQuery bool) bool { 47 if pmUserID != nil { 48 if msg.ToUserID == nil { 49 return false 50 } 51 if *msg.ToUserID == *pmUserID || msg.UserID == *pmUserID { 52 return true 53 } 54 } 55 if (pmOnlyQuery == database.PmOnly && msg.ToUser == nil) || 56 (pmOnlyQuery == database.PmNone && msg.ToUser != nil) || 57 !authUser.GetDisplayModerators() && msg.Moderators || 58 !displayHellbanned && msg.IsHellbanned { 59 return false 60 } 61 62 if !authUser.GetDisplayIgnored() { 63 ignoredUsersIDs, _ := db.GetIgnoredUsersIDs(authUser.GetID()) 64 if utils.InArr(msg.UserID, ignoredUsersIDs) { 65 return false 66 } 67 } 68 69 if mentionsOnlyQuery && !strings.Contains(msg.Message, authUser.GetUsername().AtStr()) { 70 return false 71 } 72 return true 73 } 74 75 func soundNotifications(msg *database.ChatMessage, authUser database.IUserRenderMessage, renderedMsg *string) (out string) { 76 var newMessageSound, taggedSound, pmSound bool 77 if msg.User.ID != authUser.GetID() && !msg.SkipNotify { 78 newMessageSound = true 79 if strings.Contains(*renderedMsg, authUser.GetUsername().AtStr()) { 80 taggedSound = true 81 } 82 if msg.IsPmRecipient(authUser.GetID()) { 83 pmSound = true 84 } 85 } 86 if (authUser.GetNotifyTagged() && taggedSound) || (authUser.GetNotifyPmmed() && pmSound) { 87 out = `<audio src="/public/mp3/sound5.mp3" autoplay></audio>` 88 } else if authUser.GetNotifyNewMessage() && newMessageSound { 89 out = `<audio src="/public/mp3/sound6.mp3" autoplay></audio>` 90 } 91 return 92 } 93 94 type Alternator struct { 95 state bool 96 fmt, animation string 97 } 98 99 func newAlternator(fmt, animation string) *Alternator { 100 return &Alternator{fmt: fmt, animation: animation} 101 } 102 103 func (a *Alternator) alternate() string { 104 a.state = !a.state 105 return fmt.Sprintf(a.fmt, a.animation+utils.Ternary(a.state, "1", "2")) 106 } 107 108 func ChatStreamMessagesHandler(c echo.Context) error { 109 db := c.Get("database").(*database.DkfDB) 110 authUser := c.Get("authUser").(*database.User) 111 csrf, _ := c.Get("csrf").(string) 112 113 queryParams := c.QueryParams() 114 _, mlFound := queryParams["ml"] 115 _, hrmFound := queryParams["hrm"] 116 _, hideTsFound := queryParams["hide_ts"] 117 118 roomName := c.Param("roomName") 119 room, roomKey, err := dutils.GetRoomAndKey(db, c, roomName) 120 if err != nil { 121 return c.Redirect(http.StatusFound, "/") 122 } 123 124 streamItem, err := stream.SetStreaming(c, authUser.ID, "") 125 if err != nil { 126 return nil 127 } 128 defer streamItem.Cleanup() 129 130 // Keep track of how many bytes we sent on the http request, so we can auto-refresh when passing a threshold 131 bytesSent := 0 132 send := func(s string) { 133 n, _ := c.Response().Write([]byte(s)) 134 bytesSent += n 135 } 136 137 data := ChatMessagesData{} 138 data.TopBarQueryParams = utils.TernaryOrZero(mlFound, "&ml=1") 139 data.HideRightColumn = authUser.HideRightColumn || hrmFound 140 data.HideTimestamps = authUser.GetDateFormat() == "" || hideTsFound 141 142 // Register modals and send the css for them 143 modalsManager := streamModals.NewModalsManager() 144 modalsManager.Register(streamModals.NewCodeModal(authUser.ID, room)) 145 if authUser.IsAdmin { 146 modalsManager.Register(streamModals.NewPurgeModal(authUser.ID, room)) 147 } 148 send(modalsManager.Css()) 149 150 data.ReadMarker, _ = db.GetUserReadMarker(authUser.ID, room.ID) 151 data.ChatMenuData.RoomName = room.Name 152 data.ManualRefreshTimeout = 0 153 send(GenerateStyle(authUser, data)) 154 if authUser.DisplayAliveIndicator { 155 send(`<div id="i"></div>`) // http alive indicator; green/red dot 156 } 157 send(fmt.Sprintf(`<div style="display:flex;flex-direction:column-reverse;" id="msgs">`)) 158 159 // Get initial messages for the user 160 pmOnlyQuery := dutils.DoParsePmDisplayMode(c.QueryParam("pmonly")) 161 mentionsOnlyQuery := utils.DoParseBool(c.QueryParam("mentionsOnly")) 162 pmUserID := dutils.GetUserIDFromUsername(db, c.QueryParam(command.RedirectPmUsernameQP)) 163 displayHellbanned := authUser.DisplayHellbanned || authUser.IsHellbanned 164 displayIgnoredMessages := utils.False() 165 msgs, err := db.GetChatMessages(room.ID, roomKey, authUser.Username, authUser.ID, pmUserID, pmOnlyQuery, mentionsOnlyQuery, 166 displayHellbanned, authUser.DisplayIgnored, authUser.DisplayModerators, displayIgnoredMessages, 150, 0) 167 if err != nil { 168 return c.Redirect(http.StatusFound, "/") 169 } 170 171 // Render the messages as html 172 data.Messages = msgs 173 data.NbButtons = authUser.CountUIButtons() 174 nullUsername := config.NullUsername 175 hasNoMsgs := len(data.Messages) == 0 176 send("<div>" + RenderMessages(authUser, data, csrf, nullUsername, nil, false) + "</div>") 177 c.Response().Flush() 178 179 // Create a subscriber and topics to listen to 180 selfRefreshLoadingIconTopic := "refresh_loading_icon_" + string(authUser.Username) 181 selfRefreshTopic := "refresh_" + string(authUser.Username) 182 selfWizzTopic := "wizz_" + string(authUser.Username) 183 readMarkerTopic := "readmarker_" + authUser.ID.String() 184 authorizedTopics := []string{ 185 database.RefreshTopic, 186 selfRefreshTopic, 187 selfRefreshLoadingIconTopic, 188 selfWizzTopic, 189 readMarkerTopic, 190 "room_" + room.ID.String()} 191 authorizedTopics = append(authorizedTopics, modalsManager.Topics()...) 192 sub := database.MsgPubSub.Subscribe(authorizedTopics) 193 defer sub.Close() 194 195 // Keep track of messages that are after the read-marker (unread). 196 // When we receive a "delete msg", and this map is empty, we should hide the read-marker 197 // as it means the read marker is now at the very top. 198 msgsMap := hashset.New[int64]() 199 for _, msg := range msgs { 200 if msg.CreatedAt.After(data.ReadMarker.ReadAt) { 201 msgsMap.Set(msg.ID) 202 } 203 } 204 205 // If the read-marker is at the very top, it will be hidden and need to be displayed when we receive a new message. 206 // If it is not at the top, it will already be visible and does not need to be displayed again. 207 var displayReadMarker bool 208 if len(msgs) > 0 { 209 fstMsgTsRound := msgs[0].CreatedAt.Round(time.Second) 210 readMarkerTsRound := data.ReadMarker.ReadAt.Round(time.Second) 211 displayReadMarker = !fstMsgTsRound.After(readMarkerTsRound) 212 } 213 214 // Keep track of current read-marker revision 215 readMarkerRev := 0 216 // Function to hide current rev of read marker and insert an invisible one at the top. 217 updateReadMarker := func() { 218 if authUser.ChatReadMarkerEnabled { 219 send(fmt.Sprintf(`<style>.read-marker-%d{display:none !important;}</style>`, readMarkerRev)) 220 send(fmt.Sprintf(`<div class="read-marker read-marker-%d" style="display:none;"></div>`, readMarkerRev+1)) 221 } 222 readMarkerRev++ 223 displayReadMarker = true 224 } 225 // Function to show the invisible read-marker which used to be at the top. 226 showReadMarker := func() { 227 if displayReadMarker { 228 if authUser.ChatReadMarkerEnabled { 229 send(fmt.Sprintf(`<style>.read-marker-%d{display:block !important;}</style>`, readMarkerRev)) 230 } 231 displayReadMarker = false 232 } 233 } 234 235 // Toggle between true/false every 5sec. This bool keep track of which class to send for our "online indicator" 236 // We need to change the css class in order for the css to never actually complete the animation and stay "green". 237 indicatorAlt := newAlternator(`<style>#i{animation: %s 30s forwards}</style>`, "i") 238 wizzAlt := newAlternator(`<style>#msgs{animation: %s 0.25s linear 7;}</style>`, "horizontal-shaking") 239 240 Loop: 241 for { 242 select { 243 case <-streamItem.Quit: 244 break Loop 245 default: 246 } 247 248 // Refresh the page to prevent having it growing infinitely bigger 249 if bytesSent > 10<<20 { // 10 MB 250 send(hutils.MetaRefreshNow()) 251 return nil 252 } 253 254 authUser1, err := db.GetUserRenderMessageByID(authUser.ID) 255 if err != nil { 256 break Loop 257 } 258 259 managers.ActiveUsers.UpdateUserInRoom(room, managers.NewUserInfo(authUser1)) 260 261 // Update read record 262 db.UpdateChatReadRecord(authUser1.GetID(), room.ID) 263 264 // Toggle the "http alive indicator" class to keep the dot green 265 if authUser1.GetDisplayAliveIndicator() { 266 send(indicatorAlt.alternate()) 267 } 268 269 topic, msgTyp, err := sub.ReceiveTimeout2(5*time.Second, streamItem.Quit) 270 if err != nil { 271 if errors.Is(err, pubsub.ErrCancelled) { 272 break Loop 273 } 274 c.Response().Flush() 275 continue 276 } 277 278 // We receive this event when the "update read-marker" button is clicked. 279 // This means the user is saying that all messages are read, and read-marker should be at the very top. 280 if topic == readMarkerTopic { 281 msgsMap.Clear() // read-marker at the top, so no unread message 282 updateReadMarker() 283 c.Response().Flush() 284 continue 285 } 286 287 if topic == selfRefreshLoadingIconTopic { 288 send(hutils.MetaRefresh(1)) 289 return nil 290 } 291 292 if topic == selfRefreshTopic && msgTyp.Typ == database.Close { 293 return nil 294 } 295 296 if topic == selfRefreshTopic && msgTyp.Typ == database.Redirect { 297 send(hutils.MetaRedirectNow(msgTyp.NewURL)) 298 return nil 299 } 300 301 if topic == selfRefreshTopic || msgTyp.Typ == database.ForceRefresh { 302 send(hutils.MetaRefreshNow()) 303 return nil 304 } 305 306 if topic == selfWizzTopic || msgTyp.Typ == database.Wizz { 307 send(wizzAlt.alternate()) 308 c.Response().Flush() 309 continue 310 } 311 312 if modalsManager.Handle(db, authUser1, topic, csrf, msgTyp, send) { 313 c.Response().Flush() 314 continue 315 } 316 317 if msgTyp.Typ == database.DeleteMsg { 318 // Delete msg from the map that keep track of unread messages. 319 // If the map is now empty, we hide the read-marker. 320 msgsMap.Delete(msgTyp.Msg.ID) 321 if msgsMap.Empty() { 322 updateReadMarker() 323 } 324 325 send(fmt.Sprintf(`<style>.msgidc-%s-%d{display:none;}</style>`, msgTyp.Msg.UUID, msgTyp.Msg.Rev)) 326 c.Response().Flush() 327 continue 328 } 329 330 if msgTyp.Typ == database.EditMsg { 331 // Get all messages for the user that were created after the edited one (included) 332 msgs, err := db.GetChatMessages(room.ID, roomKey, authUser1.GetUsername(), authUser1.GetID(), pmUserID, pmOnlyQuery, 333 mentionsOnlyQuery, displayHellbanned, authUser1.GetDisplayIgnored(), authUser1.GetDisplayModerators(), 334 displayIgnoredMessages, 150, msgTyp.Msg.ID) 335 if err != nil { 336 return c.Redirect(http.StatusFound, "/") 337 } 338 339 // If no messages, continue. This might happen if the user has ignored the user making the edit. 340 if len(msgs) == 0 { 341 c.Response().Flush() 342 continue 343 } 344 345 // Generate css to hide the previous revision of these messages 346 toHide := make([]string, len(msgs)) 347 for i, msg := range msgs { 348 toHide[i] = fmt.Sprintf(".msgidc-%s-%d", msg.UUID, msg.Rev-1) 349 } 350 send(fmt.Sprintf(`<style>%s{display:none;}</style>`, strings.Join(toHide, ","))) 351 352 // Render the new revision of the messages in html 353 data.Messages = msgs 354 data.NbButtons = authUser1.CountUIButtons() 355 data.ReadMarker, _ = db.GetUserReadMarker(authUser1.GetID(), room.ID) 356 357 // Only try to redraw the read-marker if the first message 358 // that we redraw is older than our read-marker position. 359 var readMarkerRevRef *int 360 fstMsgTsRound := msgs[0].CreatedAt.Round(time.Second) 361 readMarkerTsRound := data.ReadMarker.ReadAt.Round(time.Second) 362 if !fstMsgTsRound.After(readMarkerTsRound) { 363 readMarkerRevRef = &readMarkerRev 364 } 365 366 send(RenderMessages(authUser1, data, csrf, nullUsername, readMarkerRevRef, true)) 367 368 c.Response().Flush() 369 continue 370 } 371 372 msg := &msgTyp.Msg 373 if room.IsProtected() { 374 if err := msg.Decrypt(roomKey); err != nil { 375 return c.Redirect(http.StatusFound, "/") 376 } 377 } 378 379 if !dutils.VerifyMsgAuth(db, msg, authUser1.GetID(), authUser1.IsModerator()) || 380 !applyUserFilters(db, authUser1, msg, pmUserID, pmOnlyQuery, displayHellbanned, mentionsOnlyQuery) { 381 continue 382 } 383 384 manualPreload(db, msg, room) 385 386 baseTopBarURL := "/api/v1/chat/top-bar/" + room.Name 387 if hasNoMsgs { 388 send(`<style>#no-msg{display:none}</style>`) 389 hasNoMsgs = false 390 } 391 readMarkerRendered := true 392 isFirstMsg := false 393 isEdit := utils.False() 394 renderedMsg := RenderMessage(1, *msg, authUser1, data, baseTopBarURL, &readMarkerRendered, &isFirstMsg, csrf, nullUsername, &readMarkerRev, isEdit) 395 396 // Keep track of unread messages 397 msgsMap.Set(msg.ID) 398 399 send(renderedMsg) 400 showReadMarker() 401 402 // Sound notifications 403 send(soundNotifications(msg, authUser1, &renderedMsg)) 404 405 c.Response().Flush() 406 } // end of infinite loop (LOOP) 407 408 // Display a big banner stating the connection is closed. 409 send(`<div class="connection-closed">Connection closed</div>`) 410 // Auto refresh the page after 5sec so that the client reconnect after the app has restarted 411 send(hutils.MetaRefresh(5)) 412 c.Response().Flush() 413 return nil 414 } 415 416 // ChatStreamMenuHandler return the html for the "stream" chat right-manu. 417 func ChatStreamMenuHandler(c echo.Context) error { 418 db := c.Get("database").(*database.DkfDB) 419 authUser := c.Get("authUser").(*database.User) 420 roomName := c.Param("roomName") 421 422 room, _, err := dutils.GetRoomAndKey(db, c, roomName) 423 if err != nil { 424 return c.NoContent(http.StatusForbidden) 425 } 426 427 if !authUser.UseStreamMenu { 428 data := GetChatMenuData(c, room) 429 s := utils.TernaryOrZero(!data.PreventRefresh, hutils.MetaRefresh(5)) 430 s += GenerateStyle(authUser, ChatMessagesData{}) 431 s += RenderRightColumn(authUser, data) 432 return c.HTML(http.StatusOK, s) 433 } 434 435 streamItem, err := stream.SetStreaming(c, authUser.ID, "") 436 if err != nil { 437 return nil 438 } 439 defer streamItem.Cleanup() 440 441 send := func(s string) { _, _ = c.Response().Write([]byte(s)) } 442 var prevHash uint32 443 var menuID int 444 var once utils.Once 445 446 selfRefreshLoadingIconTopic := "refresh_loading_icon_" + string(authUser.Username) 447 selfRefreshTopic := "refresh_" + string(authUser.Username) 448 sub := database.MsgPubSub.Subscribe([]string{ 449 database.RefreshTopic, 450 selfRefreshTopic, 451 selfRefreshLoadingIconTopic}) 452 defer sub.Close() 453 454 send(GenerateStyle(authUser, ChatMessagesData{})) 455 456 Loop: 457 for { 458 select { 459 case <-once.Now(): 460 case <-time.After(5 * time.Second): 461 case p := <-sub.ReceiveCh(): 462 if p.Topic == selfRefreshLoadingIconTopic { 463 send(hutils.MetaRefresh(1)) 464 return nil 465 } 466 if p.Msg.Typ == database.ForceRefresh || p.Topic == selfRefreshTopic { 467 send(hutils.MetaRefreshNow()) 468 return nil 469 } 470 if p.Msg.Typ == database.CloseMenu { 471 return nil 472 } 473 send(hutils.MetaRefresh(1)) 474 return nil 475 case <-streamItem.Quit: 476 break Loop 477 } 478 479 data := GetChatMenuData(c, room) 480 rightColumn := RenderRightColumn(authUser, data) 481 newHash := utils.Crc32([]byte(rightColumn)) 482 if newHash != prevHash { 483 send(fmt.Sprintf(`<style>#menu_%d{display:none}</style><div id="menu_%d">%s</div>`, menuID, menuID+1, rightColumn)) 484 c.Response().Flush() 485 prevHash = newHash 486 menuID++ 487 } 488 } 489 send(hutils.MetaRefresh(5)) 490 c.Response().Flush() 491 return nil 492 } 493 494 func ChatStreamMessagesRefreshHandler(c echo.Context) error { 495 authUser := c.Get("authUser").(*database.User) 496 database.MsgPubSub.Pub("refresh_loading_icon_"+string(authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh}) 497 poker.PubSub.Pub("refresh_loading_icon_"+string(authUser.Username), poker.RefreshLoadingIconEvent{}) 498 return hutils.RedirectReferer(c) 499 }