History Modal + Dark Detector Fixes

* Add a History button under the DMs where an authenticated JWT user can page
  through the usernames they have stored history with, to bring up those DMs
  even if their chat partner is not currently online.
* Try some fixes to the dark video detector to see if it reduces false
  positives: wait for the canplaythrough video event to fire before beginning
  the dark detection. Also, increase the threshold so dark frames need to be
  seen 5 times in a row instead of twice before cutting the camera.
This commit is contained in:
Noah 2025-02-13 20:49:36 -08:00
parent e954799fc4
commit 74a756d1ef
6 changed files with 486 additions and 12 deletions

View File

@ -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.

View File

@ -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(&params); 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

View File

@ -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.

View File

@ -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"))))

View File

@ -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.<br><br>
@ -3081,13 +3105,9 @@ export default {
<button type="button" onclick="SendMessage('/debug-dark-video')" class="button is-small is-link is-outlined">click here</button> 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"></ProfileModal>
<!-- DMs History of Usernames Modal -->
<MessageHistoryModal
:visible="messageHistoryModal.visible"
:jwt="jwt.token"
@open-chat="openDMs"
@cancel="messageHistoryModal.visible = false"
></MessageHistoryModal>
<div class="chat-container">
<!-- Top header panel -->
@ -4748,6 +4776,14 @@ export default {
</a>
</li>
<!-- History button -->
<li v-if="jwt.token">
<a href="#" @click.prevent="messageHistoryModal.visible = !messageHistoryModal.visible">
<i class="fa fa-clock mr-1"></i>
History
</a>
</li>
</ul>
</aside>

View File

@ -0,0 +1,177 @@
<script>
export default {
props: {
visible: Boolean,
jwt: String, // caller's JWT token for authorization
},
data() {
return {
busy: false,
sort: "newest",
page: 1,
pages: 0,
count: 0,
usernames: [],
// Error messaging from backend
error: null,
};
},
watch: {
visible() {
if (this.visible) {
this.refresh();
} else {
this.error = null;
this.busy = false;
}
},
sort() {
this.page = 1;
this.refresh();
},
},
methods: {
refresh() {
this.busy = true;
return fetch("/api/message/usernames", {
method: "POST",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"JWTToken": this.jwt,
"Sort": this.sort,
"Page": this.page,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.Error) {
this.error = data.Error;
return;
}
this.pages = data.Pages;
this.count = data.Count;
this.usernames = data.Usernames;
}).catch(resp => {
this.error = resp;
}).finally(() => {
this.busy = false;
})
},
gotoPrevious() {
this.page--;
if (this.page < 1) {
this.page = 1;
}
this.refresh();
},
gotoNext() {
this.page++;
if (this.page > this.pages) {
this.page = this.pages;
}
if (this.page < 1) {
this.page = 1;
}
this.refresh();
},
openChat(username) {
this.$emit("open-chat", {
username: username,
});
},
cancel() {
this.$emit("cancel");
},
},
}
</script>
<template>
<!-- DM Username History Modal -->
<div class="modal" :class="{ 'is-active': visible }">
<div class="modal-background" @click="cancel()"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-success">
<p class="card-header-title">Direct Message History</p>
</header>
<div class="card-content">
<div v-if="busy">
<i class="fa fa-spinner fa-spin mr-2"></i>
Loading...
</div>
<div v-else-if="error" class="has-text-danger">
<i class="fa fa-exclamation-triangle mr-2"></i>
<strong class="has-text-danger">Error:</strong>
{{ error }}
</div>
<div v-else>
<p class="block">
Found {{ count }} username{{ count === 1 ? '' : 's' }} that you had chatted with
(page {{ page }} of {{ pages }}).
</p>
<!-- Pagination row -->
<div class="columns block is-mobile">
<div class="column">
<button type="button" class="button is-small mr-2"
:disabled="page === 1"
@click="gotoPrevious">
Previous
</button>
<button type="button" class="button is-small"
:disabled="page >= pages"
@click="gotoNext">
Next page
</button>
</div>
<div class="column is-narrow">
<div class="select is-small">
<select v-model="sort">
<option value="newest">Most recent</option>
<option value="oldest">Oldest</option>
<option value="a-z">Username (a-z)</option>
<option value="z-a">Username (z-a)</option>
</select>
</div>
</div>
</div>
<div class="columns block is-multiline">
<div class="column is-one-third is-clipped nowrap"
v-for="username in usernames"
v-bind:key="username">
<a href="#" @click.prevent="openChat(username); cancel()" class="truncate-text-line">
<img src="/static/img/shy.png" class="mr-1" width="12" height="12">
{{ username }}
</a>
</div>
</div>
</div>
</div>
<footer class="card-footer">
<a href="#" @click.prevent="cancel()" class="card-footer-item">
Close
</a>
</footer>
</div>
</div>
</div>
</template>
<style scoped>
.nowrap {
white-space: nowrap;
}
</style>