diff --git a/docs/API.md b/docs/API.md index c57bc4f..913c259 100644 --- a/docs/API.md +++ b/docs/API.md @@ -279,6 +279,37 @@ The "Remaining" integer in the result shows how many older messages still remain to be retrieved, and tells the front-end page that it can request another page. +## POST /api/message/usernames + +This endpoint lists and paginates the usernames that the current user has DM +chat history stored with. It powers the History modal on the chat room for +authenticated users. + +The request body payload looks like: + +```json +{ + "JWTToken": "the caller's chat jwt token", + "Sort": "newest", + "Page": 1 +} +``` + +Valid options for "Sort" include: newest, oldest, a-z, z-a. The latter two are +to sort by usernames ascending and descending, instead of message timestamp. + +The response JSON looks like: + +```json +{ + "OK": true, + "Error": "only on error responses", + "Usernames": [ "alice", "bob" ], + "Count": 18, + "Pages": 2 +} +``` + ## POST /api/message/clear Clear stored direct message history for a user. diff --git a/pkg/api.go b/pkg/api.go index 38a2d9c..6738919 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -881,6 +881,139 @@ func (s *Server) MessageHistory() http.HandlerFunc { }) } +// MessageUsernameHistory (/api/message/usernames) fetches past conversation threads for a user. +// +// This endpoint will paginate the distinct usernames that the current user has +// conversation history with. It drives the 'History' button that enables users to +// load previous chats even if their chat partner is not presently online. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "JWTToken": "the caller's jwt token", +// "Sort": "newest", +// "Page": 1 +// } +// +// The "Sort" parameter takes the following options: +// +// - "newest": the most recently updated threads are first (default). +// - "oldest": the oldest threads are first. +// - "a-z": sort usernames ascending. +// - "z-a": sort usernames descending. +// +// The response JSON will look like the following: +// +// { +// "OK": true, +// "Error": "only on error responses", +// "Usernames": [ +// "alice", +// "bob" +// ], +// "Pages": 42, +// } +// +// The Remaining value is how many older messages still exist to be loaded. +func (s *Server) MessageUsernameHistory() http.HandlerFunc { + type request struct { + JWTToken string + Sort string + Page int + } + + type result struct { + OK bool + Error string `json:",omitempty"` + Usernames []string + Count int + Pages int + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JSON writer for the response. + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + // Parse the request. + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only POST methods allowed", + }) + return + } else if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only application/json content-types allowed", + }) + return + } + + defer r.Body.Close() + + // Parse the request payload. + var ( + params request + dec = json.NewDecoder(r.Body) + ) + if err := dec.Decode(¶ms); err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Are JWT tokens enabled on the server? + if !config.Current.JWT.Enabled || params.JWTToken == "" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "JWT authentication is not available.", + }) + return + } + + // Validate the user's JWT token. + claims, _, err := jwt.ParseAndValidate(params.JWTToken) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Get the user from the chat roster. + sub, err := s.GetSubscriber(claims.Subject) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "You are not logged into the chat room.", + }) + return + } + + // Fetch a page of message history. + usernames, count, pages, err := models.PaginateUsernames(sub.Username, params.Sort, params.Page, 9) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + enc.Encode(result{ + OK: true, + Usernames: usernames, + Count: count, + Pages: pages, + }) + }) +} + // ClearMessages (/api/message/clear) deletes all the stored direct messages for a user. // // It can be called by the authenticated user themself (with JWTToken), or from your website diff --git a/pkg/models/direct_messages.go b/pkg/models/direct_messages.go index 258edd5..d5f6e06 100644 --- a/pkg/models/direct_messages.go +++ b/pkg/models/direct_messages.go @@ -221,6 +221,102 @@ func PaginateDirectMessages(fromUsername, toUsername string, beforeID int64) ([] return result, remaining, nil } +// PaginateUsernames returns a page of usernames that the current user has conversations with. +// +// Returns the usernames, total count, pages, and error. +func PaginateUsernames(fromUsername, sort string, page, perPage int) ([]string, int, int, error) { + if DB == nil { + return nil, 0, 0, ErrNotInitialized + } + + var ( + result = []string{} + count int // Total count of usernames + pages int // Number of pages available + offset = (page - 1) * perPage + orderBy string + + // Channel IDs. + channelIDs = []string{ + fmt.Sprintf(`@%s:%%`, fromUsername), + fmt.Sprintf(`%%:@%s`, fromUsername), + } + ) + + if offset < 0 { + offset = 0 + } + + // Whitelist the sort strings. + switch sort { + case "a-z": + orderBy = "username ASC" + case "z-a": + orderBy = "username DESC" + case "oldest": + orderBy = "timestamp ASC" + default: + // default = newest + orderBy = "timestamp DESC" + } + + rows, err := DB.Query( + // Note: for some reason, the SQLite driver doesn't allow a parameterized + // query for ORDER BY (e.g. "ORDER BY ?") - so, since we have already + // whitelisted acceptable orders, use a Sprintf to interpolate that + // directly into the query. + fmt.Sprintf(` + SELECT distinct(username) + FROM direct_messages + WHERE ( + channel_id LIKE ? + OR channel_id LIKE ? + ) + AND username <> ? + ORDER BY %s + LIMIT ? + OFFSET ?`, + orderBy, + ), + channelIDs[0], channelIDs[1], fromUsername, perPage, offset, + ) + if err != nil { + return nil, 0, 0, err + } + + for rows.Next() { + var username string + if err := rows.Scan( + &username, + ); err != nil { + return nil, 0, 0, err + } + + result = append(result, username) + } + + // Get a total count of usernames. + row := DB.QueryRow(` + SELECT COUNT(distinct(username)) + FROM direct_messages + WHERE ( + channel_id LIKE ? + OR channel_id LIKE ? + ) + AND username <> ? + `, channelIDs[0], channelIDs[1], fromUsername) + if err := row.Scan(&count); err != nil { + return nil, 0, 0, err + } + + pages = int(math.Ceil(float64(count) / float64(perPage))) + if pages < 1 { + pages = 1 + } + + return result, count, pages, nil +} + // CreateChannelID returns a deterministic channel ID for a direct message conversation. // // The usernames (passed in any order) are sorted alphabetically and composed into the channel ID. diff --git a/pkg/server.go b/pkg/server.go index 725434a..f338414 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -58,6 +58,7 @@ func (s *Server) Setup() error { mux.Handle("/api/shutdown", s.ShutdownAPI()) mux.Handle("/api/profile", s.UserProfile()) mux.Handle("/api/message/history", s.MessageHistory()) + mux.Handle("/api/message/usernames", s.MessageUsernameHistory()) mux.Handle("/api/message/clear", s.ClearMessages()) mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static")))) diff --git a/src/App.vue b/src/App.vue index 84c0f93..c0ebd72 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,6 +14,7 @@ import MessageBox from './components/MessageBox.vue'; import WhoListRow from './components/WhoListRow.vue'; import VideoFeed from './components/VideoFeed.vue'; import ProfileModal from './components/ProfileModal.vue'; +import MessageHistoryModal from './components/MessageHistoryModal.vue'; import ChatClient from './lib/ChatClient'; import LocalStorage from './lib/LocalStorage'; @@ -68,6 +69,7 @@ export default { WhoListRow, VideoFeed, ProfileModal, + MessageHistoryModal, }, data() { return { @@ -234,7 +236,8 @@ export default { lastImage: null, // data: uri of last screenshot taken lastAverage: [], // last average RGB color lastAverageColor: "rgba(255, 0, 255, 1)", - wasTooDark: false, // previous average was too dark + tooDarkFrames: 0, // frame counter for dark videos + tooDarkFramesLimit: 4, // frames in a row of too dark before cut // Configuration thresholds: how dark is too dark? (0-255) // NOTE: 0=disable the feature. @@ -385,6 +388,10 @@ export default { user: {}, username: "", }, + + messageHistoryModal: { + visible: false, + }, } }, mounted() { @@ -658,7 +665,14 @@ export default { currentDMPartner() { // If you are currently in a DM channel, get the User object of your partner. if (!this.isDM) return {}; - return this.whoMap[this.normalizeUsername(this.channel)]; + + // If the user is not in the Who Map (e.g. from the history modal and the user is not online) + let username = this.normalizeUsername(this.channel); + if (this.whoMap[username] == undefined) { + return {}; + } + + return this.whoMap[username]; }, pageTitleUnreadPrefix() { // When the page is not focused, put count of unread DMs in the title bar. @@ -2287,8 +2301,8 @@ export default { this.updateWebRTCStreams(); } - // Begin dark video detection. - this.initDarkVideoDetection(); + // Begin dark video detection as soon as the video is ready to capture frames. + this.webcam.elem.addEventListener("canplaythrough", this.initDarkVideoDetection); // Begin monitoring for speaking events. this.initSpeakingEvents(this.username, this.webcam.elem); @@ -2709,7 +2723,7 @@ export default { return 'fa-video'; }, isUsernameOnCamera(username) { - return this.whoMap[username].video & VideoFlag.Active; + return this.whoMap[username]?.video & VideoFlag.Active; }, webcamButtonClass(username) { // This styles the convenient video button that appears in the header bar @@ -3029,6 +3043,9 @@ export default { this.webcam.darkVideo.ctx = ctx; } + // Reset the dark frame counter. + this.webcam.darkVideo.tooDarkFrames = 0; + if (this.webcam.darkVideo.interval !== null) { clearInterval(this.webcam.darkVideo.interval); } @@ -3040,6 +3057,9 @@ export default { if (this.webcam.darkVideo.interval !== null) { clearInterval(this.webcam.darkVideo.interval); } + + // Remove the canplaythrough event, it will be re-added if the user restarts their cam. + this.webcam.elem.removeEventListener("canplaythrough", this.initDarkVideoDetection); }, darkVideoInterval() { if (!this.webcam.active) { // safety @@ -3071,8 +3091,12 @@ export default { // If the average total color is below the threshold (too dark of a video). let averageBrightness = Math.floor((rgb[0] + rgb[1] + rgb[2]) / 3); if (averageBrightness < this.webcam.darkVideo.threshold) { - if (this.wasTooDark) { - // Last sample was too dark too, = cut the camera. + + // Count for how many frames their camera is too dark. + this.webcam.darkVideo.tooDarkFrames++; + + // After too long, cut their camera. + if (this.webcam.darkVideo.tooDarkFrames >= this.webcam.darkVideo.tooDarkFramesLimit) { this.stopVideo(); this.ChatClient(` Your webcam was too dark to see anything and has been turned off.

@@ -3081,13 +3105,9 @@ export default { to see diagnostic information and contact a chat room moderator for assistance. `); - } else { - // Mark that this frame was too dark, if the next sample is too, - // cut their camera. - this.wasTooDark = true; } } else { - this.wasTooDark = false; + this.webcam.darkVideo.tooDarkFrames = 0; } }, getAverageRGB(ctx) { @@ -4591,6 +4611,14 @@ export default { @report="doCustomReport" @cancel="profileModal.visible = false"> + + +
@@ -4748,6 +4776,14 @@ export default { + + +
  • + + + History + +
  • diff --git a/src/components/MessageHistoryModal.vue b/src/components/MessageHistoryModal.vue new file mode 100644 index 0000000..fa091c4 --- /dev/null +++ b/src/components/MessageHistoryModal.vue @@ -0,0 +1,177 @@ + + + + +