User Notes + Bring Back Online Chatters List

New feature: User Notes
* Add a "Notes" tab to user profile pages and galleries.
* Users can create one private note about another user.
* Admins can see all notes left about a user.
* Admins also see Feedback & Reports regarding the user on that page.

Bring back the online chatters list
* The Usernames are filtered down based on blocklist status.
This commit is contained in:
Noah Petherbridge 2023-09-16 13:46:26 -07:00
parent a70e1c2b73
commit 49b5387750
16 changed files with 688 additions and 12 deletions

View File

@ -15,6 +15,8 @@ var (
PageSizePrivatePhotoGrantees = 12 PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20 PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20 PageSizeAdminFeedback = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16 PageSizeSiteGallery = 16
PageSizeUserGallery = 16 PageSizeUserGallery = 16
PageSizeInboxList = 20 // sidebar list PageSizeInboxList = 20 // sidebar list

View File

@ -119,6 +119,7 @@ func Profile() http.HandlerFunc {
"IsFriend": isFriend, "IsFriend": isFriend,
"IsPrivate": isPrivate, "IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser), "PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username), "OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes the photo. // Details on who likes the photo.

View File

@ -0,0 +1,160 @@
package account
import (
"net/http"
"net/url"
"regexp"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
// User notes page (/notes/u/username)
func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username string
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "You must be signed in to view this page.")
templates.Redirect(w, "/login?next="+url.QueryEscape(r.URL.String()))
return
}
// Is the site under a Maintenance Mode restriction?
if middleware.MaintenanceMode(currentUser, w, r) {
return
}
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Look up our current note about this person.
var myNote = models.GetNoteBetweenUsers(currentUser, user)
// Are we submitting a note?
if r.Method == http.MethodPost {
var message = r.FormValue("message")
// Update & save our note.
if message == "" {
// Delete it.
if err := myNote.Delete(); err != nil && err != models.ErrNoUserNoteToDelete {
session.FlashError(w, r, "Error deleting your note: %s", err)
} else if err == nil {
session.Flash(w, r, "Your note was deleted!")
}
} else {
// Update it.
myNote.Message = message
if err := myNote.Save(); err != nil {
session.FlashError(w, r, "Error saving your note: %s", err)
} else {
session.Flash(w, r, "Your notes have been saved!")
}
}
templates.Redirect(w, r.URL.Path)
return
}
// Admin view: paginate their feedback & reports.
var (
feedback = []*models.Feedback{}
otherNotes = []*models.UserNote{}
userMap = models.UserMap{}
notePager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeAdminUserNotes,
Sort: "updated_at desc",
}
fbPager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeAdminFeedbackNotesPage,
Sort: "created_at desc",
}
)
notePager.ParsePage(r)
fbPager.ParsePage(r)
if currentUser.IsAdmin {
// Paginate all notes written about this user.
if on, err := models.PaginateUserNotes(user, notePager); err != nil {
session.FlashError(w, r, "Paginating user notes: %s", err)
} else {
otherNotes = on
}
// Paginate feedback & reports.
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else {
feedback = fb
}
// Map user IDs for the Feedback Reply-To line and the Note Givers for the paginated notes.
var userIDs = []uint64{}
for _, p := range feedback {
if p.UserID > 0 {
userIDs = append(userIDs, p.UserID)
}
}
for _, p := range otherNotes {
if p.UserID > 0 {
userIDs = append(userIDs, p.UserID)
}
}
if um, err := models.MapUsers(currentUser, userIDs); err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
} else {
userMap = um
}
}
vars := map[string]interface{}{
"User": user,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"MyNote": myNote,
// Admin concerns.
"Feedback": feedback,
"FeedbackPager": fbPager,
"OtherNotes": otherNotes,
"NotePager": notePager,
"UserMap": userMap,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
@ -154,7 +155,7 @@ func Landing() http.HandlerFunc {
"IsShyUser": isShy, "IsShyUser": isShy,
// Pre-populate the "who's online" widget from backend cache data // Pre-populate the "who's online" widget from backend cache data
"ChatStatistics": worker.GetChatStatistics(), "ChatStatistics": FilteredChatStatistics(currentUser),
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -163,6 +164,35 @@ func Landing() http.HandlerFunc {
}) })
} }
// FilteredChatStatistics will return a copy of the cached ChatStatistics but where the Usernames list is
// filtered down (and the online user counts, accordingly) by blocklists.
func FilteredChatStatistics(currentUser *models.User) worker.ChatStatistics {
var stats = worker.GetChatStatistics()
var result = worker.ChatStatistics{
UserCount: stats.UserCount,
Usernames: []string{},
Cameras: stats.Cameras,
}
// Who are we blocking?
var blockedUsernames = map[string]interface{}{}
for _, username := range models.BlockedUsernames(currentUser.ID) {
blockedUsernames[username] = nil
}
// Filter the online users listing.
for _, username := range stats.Usernames {
if _, ok := blockedUsernames[username]; !ok {
result.Usernames = append(result.Usernames, username)
}
}
// Sort the names.
sort.Strings(result.Usernames)
return result
}
// SendBlocklist syncs the user blocklist to the chat server prior to sending them over. // SendBlocklist syncs the user blocklist to the chat server prior to sending them over.
func SendBlocklist(user *models.User) error { func SendBlocklist(user *models.User) error {
// Get the user's blocklist. // Get the user's blocklist.

View File

@ -148,6 +148,7 @@ func UserPhotos() http.HandlerFunc {
"User": user, "User": user,
"Photos": photos, "Photos": photos,
"PhotoCount": models.CountPhotosICanSee(user, currentUser), "PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"PublicPhotoCount": models.CountPublicPhotos(user.ID), "PublicPhotoCount": models.CountPublicPhotos(user.ID),
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos, "InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
"Pager": pager, "Pager": pager,

View File

@ -35,6 +35,7 @@ func DeleteUser(user *models.User) error {
{"Messages", DeleteUserMessages}, {"Messages", DeleteUserMessages},
{"Friends", DeleteFriends}, {"Friends", DeleteFriends},
{"Profile Fields", DeleteProfile}, {"Profile Fields", DeleteProfile},
{"User Notes", DeleteUserNotes},
} }
for _, item := range todo { for _, item := range todo {
if err := item.Fn(user.ID); err != nil { if err := item.Fn(user.ID); err != nil {
@ -280,3 +281,13 @@ func DeleteComments(userID uint64) error {
).Delete(&models.Comment{}) ).Delete(&models.Comment{})
return result.Error return result.Error
} }
// DeleteUserNotes scrubs data for deleting a user.
func DeleteUserNotes(userID uint64) error {
log.Error("DeleteUser: DeleteUserNotes(%d)", userID)
result := models.DB.Where(
"user_id = ? OR about_user_id = ?",
userID, userID,
).Delete(&models.UserNote{})
return result.Error
}

View File

@ -76,6 +76,41 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
return fb, result.Error return fb, result.Error
} }
// PaginateFeedbackAboutUser digs through feedback about a specific user ID or one of their Photos.
//
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
// username.
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
photoIDs, _ = user.AllPhotoIDs()
wheres = []string{}
placeholders = []interface{}{}
)
wheres = append(wheres, `
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, photoIDs)
query := DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(
pager.Sort,
)
query.Model(&Feedback{}).Count(&pager.Total)
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&fb)
return fb, result.Error
}
// CreateFeedback saves a new Feedback row to the DB. // CreateFeedback saves a new Feedback row to the DB.
func CreateFeedback(fb *Feedback) error { func CreateFeedback(fb *Feedback) error {
result := DB.Create(fb) result := DB.Create(fb)

View File

@ -29,4 +29,5 @@ func AutoMigrate() {
DB.AutoMigrate(&AdminGroup{}) DB.AutoMigrate(&AdminGroup{})
DB.AutoMigrate(&AdminScope{}) DB.AutoMigrate(&AdminScope{})
DB.AutoMigrate(&UserLocation{}) DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{})
} }

View File

@ -100,6 +100,25 @@ func (u *User) AllPrivatePhotoIDs() ([]uint64, error) {
return photoIDs, nil return photoIDs, nil
} }
// AllPhotoIDs returns the listing of all IDs of the user's photos.
func (u *User) AllPhotoIDs() ([]uint64, error) {
var photoIDs = []uint64{}
err := DB.Table(
"photos",
).Select(
"photos.id AS id",
).Where(
"user_id = ?",
u.ID,
).Scan(&photoIDs)
if err.Error != nil {
return photoIDs, fmt.Errorf("AllPhotoIDs(%s): %s", u.Username, err.Error)
}
return photoIDs, nil
}
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see. // IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool { func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
pb := &PrivatePhoto{} pb := &PrivatePhoto{}

112
pkg/models/user_note.go Normal file
View File

@ -0,0 +1,112 @@
package models
import (
"errors"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
)
// UserNote table where users can write private notes about each other.
type UserNote struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // author ID
AboutUserID uint64 `gorm:"index"` // target user ID
Message string
CreatedAt time.Time
UpdatedAt time.Time
}
// GetNoteBetweenUsers finds the existing note or creates an empty model if not found.
func GetNoteBetweenUsers(currentUser *User, user *User) *UserNote {
var (
note = &UserNote{}
result = DB.Model(note).Where(
"user_id = ? AND about_user_id = ?",
currentUser.ID, user.ID,
).First(&note)
)
if result.Error != nil {
note = &UserNote{
UserID: currentUser.ID,
AboutUserID: user.ID,
}
}
return note
}
// CountNotesAboutUser returns the number of notes (the current user) has about the other user.
//
// For regular user, will return zero or one; for admins, will return the total count of notes
// left by any other users about this user.
func CountNotesAboutUser(currentUser *User, user *User) int64 {
var (
wheres = []string{}
placeholders = []interface{}{}
count int64
)
if currentUser.IsAdmin {
wheres = append(wheres, "about_user_id = ?")
placeholders = append(placeholders, user.ID)
} else {
wheres = append(wheres, "user_id = ? AND about_user_id = ?")
placeholders = append(placeholders, currentUser.ID, user.ID)
}
query := DB.Model(&UserNote{}).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Count(&count)
if query.Error != nil {
log.Error("CountNotesAboutUser(%s, %s): %s", currentUser.Username, user.Username, query.Error)
}
return count
}
// PaginateUserNotes shows all notes written by users about the target user.
func PaginateUserNotes(user *User, pager *Pagination) ([]*UserNote, error) {
var (
notes = []*UserNote{}
wheres = []string{}
placeholders = []interface{}{}
)
wheres = append(wheres, "about_user_id = ?")
placeholders = append(placeholders, user.ID)
query := DB.Joins("JOIN users ON users.id = user_notes.user_id").Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(
"users.is_admin desc, " + pager.Sort,
)
query.Model(&UserNote{}).Count(&pager.Total)
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&notes)
return notes, result.Error
}
// Save the note.
func (p *UserNote) Save() error {
if p.ID == 0 {
return DB.Create(p).Error
}
return DB.Save(p).Error
}
var ErrNoUserNoteToDelete = errors.New("you had no user note to delete")
// Delete the DB entry.
func (p *UserNote) Delete() error {
if p.ID == 0 {
return ErrNoUserNoteToDelete
}
return DB.Delete(p).Error
}

View File

@ -55,6 +55,7 @@ func New() http.Handler {
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
mux.Handle("/photo/private", middleware.LoginRequired(photo.Private())) mux.Handle("/photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share())) mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
mux.Handle("/notes/u/", middleware.LoginRequired(account.UserNotes()))
mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))

View File

@ -14,7 +14,7 @@ import (
// ChatStatistics is the json result of the BareRTC /api/statistics endpoint. // ChatStatistics is the json result of the BareRTC /api/statistics endpoint.
type ChatStatistics struct { type ChatStatistics struct {
UserCount int UserCount int
Usernames []string `json:",omitempty"` Usernames []string
Cameras struct { Cameras struct {
Blue int Blue int
Red int Red int
@ -41,14 +41,6 @@ func SetChatStatistics(stats *ChatStatistics) {
cachedChatStatistics = stats cachedChatStatistics = stats
} }
// Privatized returns a copy of ChatStatistics with the usernames list scrubbed.
func (cs ChatStatistics) Privatized() ChatStatistics {
return ChatStatistics{
UserCount: cs.UserCount,
Cameras: cs.Cameras,
}
}
// IsOnline returns whether the username is currently logged-in to chat. // IsOnline returns whether the username is currently logged-in to chat.
func (cs ChatStatistics) IsOnline(username string) bool { func (cs ChatStatistics) IsOnline(username string) bool {
for _, user := range cs.Usernames { for _, user := range cs.Usernames {

View File

@ -284,6 +284,17 @@
</span> </span>
</a> </a>
</li> </li>
<li>
<a href="/notes/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-pen-to-square"></i>
</span>
<span>
Notes
{{if .NoteCount}}<span class="tag is-link is-light ml-1">{{.NoteCount}}</span>{{end}}
</span>
</a>
</li>
</ul> </ul>
</div> </div>

View File

@ -0,0 +1,270 @@
{{define "title"}}
Notes about {{.User.Username}}
{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<div class="level">
<div class="level-left">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-pen-square"></i></span>
<span>{{template "title" .}}</span>
</h1>
</div>
</div>
</div>
</div>
</section>
<!-- ugly hack.. needed by the card-footers later below. -->
{{$Root := .}}
<div class="block p-4">
<!-- Tab bar -->
<div class="tabs is-boxed">
<ul>
<li>
<a href="/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user"></i>
</span>
<span>Profile</span>
</a>
</li>
<li>
<a href="/photo/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-image"></i>
</span>
<span>
Photos
{{if .PhotoCount}}<span class="tag is-link is-light ml-1">{{.PhotoCount}}</span>{{end}}
</span>
</a>
</li>
<li class="is-active">
<a>
<span class="icon is-small">
<i class="fa fa-pen-to-square"></i>
</span>
<span>
Notes
{{if .NoteCount}}<span class="tag is-link is-light ml-1">{{.NoteCount}}</span>{{end}}
</span>
</a>
</li>
</ul>
</div>
<div class="notification is-info is-light content">
<p>
<i class="fa fa-info-circle mr-1"></i> On this page you may jot down some private notes for yourself
about <strong>{{.User.Username}}</strong>, for example to remember a topic you discussed on chat or
to remember what they said their favorite color was -- it's up to you!
</p>
<p>
Your notes will not be visible to <strong>{{.User.Username}}</strong> but <em>will</em> be visible
to website administrators.
</p>
</div>
<div class="columns">
<!-- User column -->
<div class="column">
<form action="{{.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<div class="field mb-0">
<label class="label" for="message">My private notes about {{.User.Username}}</label>
<textarea class="textarea"
style="overflow: auto"
cols="80" rows="6"
name="message"
id="message"
placeholder="On one sunny day...">{{.MyNote.Message}}</textarea>
</div>
<!-- Show last updated on our note -->
{{if not .MyNote.UpdatedAt.IsZero}}
<div class="mt-1">
<em>You last updated your notes <span title="{{.MyNote.UpdatedAt.Format "Jan _2 2006"}}">{{SincePrettyCoarse .MyNote.UpdatedAt}} ago.</span></em>
</div>
{{end}}
<div class="field mt-4">
<button type="submit" class="button is-primary">
<i class="fa fa-save mr-1"></i> Save my notes
</button>
</div>
</form>
<!-- Admin view: everyone else's notes -->
{{if .CurrentUser.IsAdmin}}
<div class="card mt-6">
<div class="card-header has-background-info">
<p class="card-header-title has-text-light">
<i class="fa fa-peace mr-1"></i>
Everyone Else's Notes
</p>
</div>
<div class="card-content">
{{if .NotePager.Total}}
<p class="block">
Found <strong>{{.NotePager.Total}}</strong> note{{Pluralize64 .NotePager.Total}} about this user (page {{.NotePager.Page}} of {{.NotePager.Pages}}).
</p>
{{end}}
<p class="block">
Note: admin notes are shown first, and the rest are ordered by recently updated.
</p>
<div class="my-4">
{{SimplePager .NotePager}}
</div>
<hr>
{{range .OtherNotes}}
<div class="card has-background-link-light mb-4">
{{$User := $Root.UserMap.Get .UserID}}
<div class="card-content" style="position: relative">
<strong>From user:</strong>
<a href="/u/{{$User.Username}}">{{$User.Username}}</a>
{{if $User.IsAdmin}}
<span class="tag ml-2 is-danger is-light">
<i class="fa fa-peace mr-1"></i> Admin
</span>
{{end}}
<div class="my-2" style="white-space: pre-wrap; line-break: anywhere; overflow: auto">{{.Message}}</div>
</div>
</div>
{{end}}
<hr>
<div class="my-4">
{{SimplePager .NotePager}}
</div>
</div>
</div>
{{end}}
</div>
<!-- Admin Feedback & Notes column -->
{{if .CurrentUser.IsAdmin}}
<div class="column">
<div class="card">
<div class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<i class="fa fa-peace mr-1"></i> Admin Feedback &amp; Reports
</p>
</div>
<div class="card-content">
{{if .FeedbackPager.Total}}
<span>
Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
</span>
{{end}}
<div class="my-4">
{{SimplePager .FeedbackPager}}
</div>
{{range .Feedback}}
{{$User := $Root.UserMap.Get .UserID}}
<div class="card">
<div class="card-content">
<table class="table is-fullwidth">
<tr>
<td class="has-text-right is-narrow">
<strong>Intent:</strong>
</td>
<td>{{.Intent}}</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Subject:</strong>
</td>
<td>{{.Subject}}</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Table:</strong>
</td>
<td>
{{if eq .TableName ""}}
n/a
{{if ne .TableID 0}} - {{.TableID}}{{end}}
{{else if eq .TableName "users"}}
Users: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-external-link ml-2"
target="_blank"
title="Visit the reported user's profile"></a>
{{else if eq .TableName "photos"}}
Photos: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-external-link mx-2"
target="_blank"
title="Visit the reported photo"></a>
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true&profile=true"
class="fa fa-user"
target="_blank"
title="Visit the user profile who owns the reported photo"></a>
{{else if eq .TableName "messages"}}
Messages: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-ghost ml-2"
target="_blank"
title="Impersonate the reporter and view this message thread"></a>
{{else}}
{{.TableName}}: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
{{end}}
</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Reply&nbsp;To:</strong>
</td>
<td>
{{if $User}}
<a href="/u/{{$User.Username}}">{{$User.Username}}</a>
{{else if ne .ReplyTo ""}}
<a href="mailto:{{.ReplyTo}}">{{.ReplyTo}}</a>
{{else}}
n/a
{{end}}
</td>
</tr>
</table>
<div class="content">
{{if eq .Message ""}}
<p><em>No message attached.</em></p>
{{else}}
{{ToMarkdown .Message}}
{{end}}
</div>
</div>
</div>
{{end}}
<div class="my-4">
{{SimplePager .FeedbackPager}}
</div>
</div>
</div>
</div>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@ -44,6 +44,8 @@
<div class="notification is-success is-light" id="chatStatsBanner" style="display: none"> <div class="notification is-success is-light" id="chatStatsBanner" style="display: none">
<span id="usersOnline"></span> <span id="usersOnline"></span>
<a id="whoLink" href="#" style="visibility: hidden">Who?</a>
<span id="whoList" style="display: none"></span>
<!-- Camera stats if active --> <!-- Camera stats if active -->
<div id="cameraStats" style="display: none" class="mt-2"> <div id="cameraStats" style="display: none" class="mt-2">
@ -157,6 +159,8 @@
<script> <script>
function showWhoBanner(chatStatistics) { function showWhoBanner(chatStatistics) {
const $banner = document.querySelector("#chatStatsBanner"), const $banner = document.querySelector("#chatStatsBanner"),
$whoLink = document.querySelector("#whoLink"),
$whoList = document.querySelector("#whoList"),
$usersOnline = document.querySelector("#usersOnline") $usersOnline = document.querySelector("#usersOnline")
$cameraStats = document.querySelector("#cameraStats") $cameraStats = document.querySelector("#cameraStats")
$cameraCount = document.querySelector("#cameraCount") $cameraCount = document.querySelector("#cameraCount")
@ -167,7 +171,16 @@ function showWhoBanner(chatStatistics) {
let people = chatStatistics.UserCount === 1 ? 'person' : 'people'; let people = chatStatistics.UserCount === 1 ? 'person' : 'people';
let isAre = chatStatistics.UserCount === 1 ? 'is' : 'are'; let isAre = chatStatistics.UserCount === 1 ? 'is' : 'are';
$usersOnline.innerHTML = `There ${isAre} currently <strong>${chatStatistics.UserCount}</strong> ${people}</span> in the chat room.`; $usersOnline.innerHTML = `There ${isAre} currently <strong>${chatStatistics.UserCount}</strong> ${people}</span> in the chat room`;
$whoList.innerHTML = chatStatistics.Usernames !== null ? chatStatistics.Usernames.join(", ") : "";
// Show the "Who?" link if there's anybody.
if (chatStatistics.UserCount > 0) {
$usersOnline.innerHTML += ":";
$whoLink.style.visibility = "visible";
} else {
$usersOnline.innerHTML += ".";
}
// Camera stats too? // Camera stats too?
if (chatStatistics.Cameras.Blue + chatStatistics.Cameras.Red > 0) { if (chatStatistics.Cameras.Blue + chatStatistics.Cameras.Red > 0) {
@ -177,9 +190,15 @@ function showWhoBanner(chatStatistics) {
$cameraRed.innerHTML = chatStatistics.Cameras.Red; $cameraRed.innerHTML = chatStatistics.Cameras.Red;
$cameraBlue.innerHTML = chatStatistics.Cameras.Blue; $cameraBlue.innerHTML = chatStatistics.Cameras.Blue;
} }
$whoLink.addEventListener("click", (e) => {
e.preventDefault();
$whoLink.style.display = "none";
$whoList.style.display = "";
});
} }
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
let ChatStatistics = {{.ChatStatistics.Privatized}}, let ChatStatistics = {{.ChatStatistics}},
$banner = document.querySelector("#chatStatsBanner"), $banner = document.querySelector("#chatStatsBanner"),
$usersOnline = document.querySelector("#usersOnline"); $usersOnline = document.querySelector("#usersOnline");

View File

@ -134,6 +134,17 @@
</span> </span>
</a> </a>
</li> </li>
<li>
<a href="/notes/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-pen-to-square"></i>
</span>
<span>
Notes
{{if .NoteCount}}<span class="tag is-link is-light ml-1">{{.NoteCount}}</span>{{end}}
</span>
</a>
</li>
</ul> </ul>
</div> </div>
{{end}} {{end}}