Forum Reply Enhancements + Better Pagers

* Enhance user experience replying to a forum thread. An inline reply textarea
  is added to page footers, "Quote" buttons on posts will quote the markdown
  source and focus the reply textarea, and "Reply" buttons will put an
  "@ mention" and focus the reply textarea. Users with scripts disabled will
  still be sent to the regular reply page as before.
* Improve all pagers by adding a "QueryPlus" template function that merges the
  page number with other current query parameters.
* Fix private profile picture avatars not displaying in your Notifications for
  profile pics you're allowed to see.
pull/12/head
Noah 2022-09-10 12:09:46 -07:00
parent 90b97708f7
commit 8085e092bc
15 changed files with 238 additions and 104 deletions

View File

@ -41,6 +41,7 @@ func Dashboard() http.HandlerFunc {
// Map our notifications.
notifMap := models.MapNotifications(notifs)
models.SetUserRelationshipsInNotifications(currentUser, notifs)
var vars = map[string]interface{}{
"Notifications": notifs,

View File

@ -85,3 +85,20 @@ func SetUserRelationshipsInThreads(user *User, threads []*Thread) {
// Inject relationships into those comments' users.
SetUserRelationshipsInComments(user, comments)
}
// SetUserRelationshipsInNotifications takes a set of Notifications and sets relationship booleans on their AboutUsers.
func SetUserRelationshipsInNotifications(user *User, notifications []*Notification) {
// Gather and map the users.
var (
users = []*User{}
userMap = map[uint64]*User{}
)
for _, n := range notifications {
users = append(users, &n.AboutUser)
userMap[n.AboutUser.ID] = &n.AboutUser
}
// Inject relationships.
SetUserRelationships(user, users)
}

View File

@ -15,6 +15,11 @@ import (
"code.nonshy.com/nonshy/website/pkg/utility"
)
// Generics
type Number interface {
int | int64 | uint64 | float32 | float64
}
// TemplateFuncs available to all pages.
func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{
@ -31,68 +36,15 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
`<strong style="color: #FF77FF">shy</strong>`,
))
},
"Pluralize64": func(count int64, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
} else {
return labels[1]
}
},
"PluralizeU64": func(count uint64, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
} else {
return labels[1]
}
},
"Pluralize": func(count int, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
} else {
return labels[1]
}
},
"Substring": func(value string, n int) string {
if n > len(value) {
return value
}
return value[:n]
},
"TrimEllipses": func(value string, n int) string {
if n > len(value) {
return value
}
return value[:n] + "…"
},
"IterRange": func(start, n int) []int {
var result = []int{}
for i := start; i <= n; i++ {
result = append(result, i)
}
return result
},
"SubtractInt": func(a, b int) int {
return a - b
},
"UrlEncode": func(values ...interface{}) string {
var result string
for _, value := range values {
result += url.QueryEscape(fmt.Sprintf("%v", value))
}
return result
},
"Pluralize": Pluralize[int],
"Pluralize64": Pluralize[int64],
"PluralizeU64": Pluralize[uint64],
"Substring": Substring,
"TrimEllipses": TrimEllipses,
"IterRange": IterRange,
"SubtractInt": SubtractInt,
"UrlEncode": UrlEncode,
"QueryPlus": QueryPlus(r),
}
}
@ -124,3 +76,91 @@ func SincePrettyCoarse() func(time.Time) template.HTML {
func ToMarkdown(input string) template.HTML {
return template.HTML(markdown.Render(input))
}
// Pluralize text based on a quantity number. Provide up to 2 labels for the
// singular and plural cases, or the defaults are "", "s"
func Pluralize[V Number](count V, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
}
return labels[1]
}
// Substring safely returns the first N characters of a string.
func Substring(value string, n int) string {
if n > len(value) {
return value
}
return value[:n]
}
// TrimEllipses is like Substring but will add an ellipses if truncated.
func TrimEllipses(value string, n int) string {
if n > len(value) {
return value
}
return value[:n] + "…"
}
// IterRange returns a list of integers useful for pagination.
func IterRange(start, n int) []int {
var result = []int{}
for i := start; i <= n; i++ {
result = append(result, i)
}
return result
}
// SubtractInt subtracts two numbers.
func SubtractInt(a, b int) int {
return a - b
}
// UrlEncode escapes a series of values (joined with no delimiter)
func UrlEncode(values ...interface{}) string {
var result string
for _, value := range values {
result += url.QueryEscape(fmt.Sprintf("%v", value))
}
return result
}
// QueryPlus takes the current request's query parameters and upserts them with new values.
//
// Use it like: {{QueryPlus "page" .NextPage}}
//
// Returns the query string sans the ? prefix, like "key1=value1&key2=value2"
func QueryPlus(r *http.Request) func(...interface{}) template.URL {
return func(upsert ...interface{}) template.URL {
// Get current parameters.
var params = r.Form
// Mix in the incoming fields.
for i := 0; i < len(upsert); i += 2 {
var (
key = fmt.Sprintf("%v", upsert[i])
value interface{}
)
if len(upsert) > i {
value = upsert[i+1]
}
params[key] = []string{fmt.Sprintf("%v", value)}
}
// Assemble and return the query string.
var parts = []string{}
for k, vs := range params {
for _, v := range vs {
parts = append(parts,
fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)),
)
}
}
return template.URL(strings.Join(parts, "&"))
}
}

View File

@ -20,16 +20,16 @@
<div class="block">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -332,7 +332,7 @@
{{if .Pager.HasNext}}
<div class="has-text-centered">
<a href="{{.Request.URL.Path}}?page={{.Pager.Next}}" class="button">View older notifications</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}" class="button">View older notifications</a>
</div>
{{end}}
</div>

View File

@ -59,15 +59,15 @@
{{if .Pager}}
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.View}}&page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -51,15 +51,15 @@
{{if .Pager}}
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.View}}&page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -30,16 +30,16 @@
<div class="block p-2">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -55,16 +55,16 @@
<div class="block p-2">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -40,16 +40,16 @@
<div class="p-4">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -99,16 +99,16 @@
<div class="block p-2">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>
@ -182,13 +182,15 @@
{{if not $Root.Thread.NoReply}}
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&quote={{.ID}}" class="has-text-dark">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&quote={{.ID}}"
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}">
<span class="icon"><i class="fa fa-quote-right"></i></span>
<span>Quote</span>
</a>
</div>
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}" class="has-text-dark">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Reply</span>
</a>
@ -231,12 +233,86 @@
This thread is not accepting any new replies.
</div>
{{else}}
<div class="block p-2 has-text-right">
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Add Reply</span>
</a>
<div class="block p-2" id="reply">
<div class="card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-comment mr-2"></i>
Reply to Thread
</p>
</header>
<div class="card-content">
<form action="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" method="POST">
{{InputCSRF}}
<div class="field block">
<label for="message" class="label">Message</label>
<textarea class="textarea" cols="80" rows="6"
name="message"
id="message"
required
placeholder="Message"></textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<div class="field has-text-centered">
<button type="submit"
name="intent"
value="preview"
class="button is-link">
Preview
</button>
<button type="submit"
name="intent"
value="submit"
class="button is-success">
Post Message
</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
const $message = document.querySelector("#message");
// Enhance the in-post Quote and Reply buttons to activate the reply field
// at the page footer instead of going to the dedicated comment page.
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
const message = node.dataset.quoteBody;
node.addEventListener("click", (e) => {
e.preventDefault();
// Prepare the quoted message.
var lines = [];
for (let line of message.split("\n")) {
lines.push("> " + line);
}
$message.value += lines.join("\n") + "\n\n";
$message.scrollIntoView();
$message.focus();
});
});
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
const replyTo = node.dataset.replyTo;
node.addEventListener("click", (e) => {
e.preventDefault();
$message.value += "@" + replyTo + "\n\n";
$message.scrollIntoView();
$message.focus();
});
});
});
</script>
{{end}}

View File

@ -50,16 +50,16 @@
<div class="block">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?{{if .IsRequests}}view=requests&{{end}}page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?{{if .IsRequests}}view=requests&{{end}}page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?{{if $Root.IsRequests}}view=requests&{{end}}page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -101,10 +101,10 @@
</div>
<div class="level-right">
{{if .ThreadPager.HasPrevious}}
<a href="{{$Request.URL.Path}}?page={{.ThreadPager.Previous}}" class="button">Previous</a>
<a href="{{$Request.URL.Path}}?{{QueryPlus "page" .ThreadPager.Previous}}" class="button">Previous</a>
{{end}}
{{if .ThreadPager.HasNext}}
<a href="{{$Request.URL.Path}}?page={{.ThreadPager.Next}}" class="button">Next Page</a>
<a href="{{$Request.URL.Path}}?{{QueryPlus "page" .ThreadPager.Next}}" class="button">Next Page</a>
{{end}}
</div>
</div>
@ -177,10 +177,10 @@
</div>
{{if .Pager.HasPrevious}}
<a href="/messages?{{if .IsSentBox}}box=sent&{{end}}page={{.Pager.Previous}}" class="button">Previous</a>
<a href="/messages?{{QueryPlus "page" .Pager.Previous}}" class="button">Previous</a>
{{end}}
{{if .Pager.HasNext}}
<a href="/messages?{{if .IsSentBox}}box=sent&{{end}}page={{.Pager.Next}}" class="button">Next Page</a>
<a href="/messages?{{QueryPlus "page" .Pager.Next}}" class="button">Next Page</a>
{{end}}
</div>
</div>

View File

@ -74,16 +74,16 @@
{{define "pager"}}
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.ViewStyle}}&page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>

View File

@ -71,16 +71,16 @@
<div class="block">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?{{if .IsGrantee}}view=grantee&{{end}}page={{.Pager.Previous}}">Previous</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?{{if .IsGrantee}}view=grantee&{{end}}page={{.Pager.Next}}">Next page</a>
href="{{.Request.URL.Path}}?{{QueryPlus "page" .Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?{{if $Root.IsGrantee}}view=grantee&{{end}}page={{.Page}}">
href="{{$Root.Request.URL.Path}}?{{QueryPlus "page" .Page}}">
{{.Page}}
</a>
</li>