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">
+
+