Comment Thread Subscriptions
* Add ability to (un)subscribe from comment threads on Forums and Photos. * Creating a forum post, replying to a post or adding a comment to a photo automatically subscribes you to be notified when somebody else adds a comment to the thing later. * At the top of each comment thread is a link to disable or re-enable your subscription. You can join a subscription without even needing to comment. If you click to disable notifications, they stay disabled even if you add another comment later.
This commit is contained in:
parent
6081aefb2f
commit
aa8d719fc4
42
README.md
42
README.md
|
@ -56,6 +56,10 @@ Run `nonshy --help` for its documentation.
|
|||
|
||||
Run `nonshy web` to start the web server.
|
||||
|
||||
```bash
|
||||
nonshy web --host 0.0.0.0 --port 8080 --debug
|
||||
```
|
||||
|
||||
## Create Admin User Accounts
|
||||
|
||||
Use the `nonshy user add` command like so:
|
||||
|
@ -70,6 +74,44 @@ $ nonshy user add --admin \
|
|||
Shorthand options `-e`, `-p` and `-u` can work in place of the longer
|
||||
options `--email`, `--password` and `--username` respectively.
|
||||
|
||||
After the first admin user is created, you may promote other users thru
|
||||
the web app by using the admin controls on their profile page.
|
||||
|
||||
## A Brief Tour of the Code
|
||||
|
||||
* `cmd/nonshy/main.go`: the entry point for the Go program.
|
||||
* `pkg/webserver.go`: the entry point for the web server.
|
||||
* `pkg/config`: mostly hard-coded configuration values - all of the page
|
||||
sizes and business logic controls are in here, set at compile time. For
|
||||
ease of local development you may want to toggle SkipEmailValidation in
|
||||
here - the signup form will then directly allow full signup with a user
|
||||
and password.
|
||||
* `pkg/controller`: the various web endpoint controllers are here,
|
||||
categorized into subpackages (account, forum, inbox, photo, etc.)
|
||||
* `pkg/log`: the logging to terminal functions.
|
||||
* `pkg/mail`: functions for delivering HTML email messages.
|
||||
* `pkg/markdown`: functions to render GitHub Flavored Markdown.
|
||||
* `pkg/middleware`: HTTP middleware functions, for things such as:
|
||||
* Session cookies
|
||||
* Authentication (LoginRequired, AdminRequired)
|
||||
* CSRF protection
|
||||
* Logging HTTP requests
|
||||
* Panic recovery for unhandled server errors
|
||||
* `pkg/models`: the SQL database models and query functions are here.
|
||||
* `pkg/models/deletion`: the code to fully scrub wipe data for
|
||||
user deletion (GDPR/CCPA compliance).
|
||||
* `pkg/photo`: photo management functions: handle uploads, scale and
|
||||
crop, generate URLs and deletion.
|
||||
* `pkg/ratelimit`: rate limiter for login attempts etc.
|
||||
* `pkg/redis`: Redis cache functions - get/set JSON values for things like
|
||||
session cookie storage and temporary rate limits.
|
||||
* `pkg/router`: the HTTP route URLs for the controllers are here.
|
||||
* `pkg/session`: functions to read/write the user's session cookie
|
||||
(log in/out, get current user, flash messages)
|
||||
* `pkg/templates`: functions to handle HTTP responses - render HTML
|
||||
templates, issue redirects, error pages, ...
|
||||
* `pkg/utility`: miscellaneous useful functions for the app.
|
||||
|
||||
## License
|
||||
|
||||
GPLv3.
|
|
@ -178,6 +178,39 @@ func PostComment() http.HandlerFunc {
|
|||
log.Error("Couldn't create Comment notification: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify subscribers to this comment thread.
|
||||
for _, userID := range models.GetSubscribers(comment.TableName, comment.TableID) {
|
||||
if notifyUser != nil && userID == notifyUser.ID {
|
||||
// Don't notify the recipient twice.
|
||||
continue
|
||||
} else if userID == currentUser.ID {
|
||||
// Don't notify the poster of the comment.
|
||||
continue
|
||||
}
|
||||
|
||||
notif := &models.Notification{
|
||||
UserID: userID,
|
||||
AboutUser: *currentUser,
|
||||
Type: models.NotificationAlsoCommented,
|
||||
TableName: comment.TableName,
|
||||
TableID: comment.TableID,
|
||||
Message: message,
|
||||
Link: fromURL,
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create Comment notification for subscriber %d: %s", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe the current user to this comment thread, so they are
|
||||
// notified if other users add followup comments.
|
||||
if _, err := models.SubscribeTo(currentUser, comment.TableName, comment.TableID); err != nil {
|
||||
log.Error("Couldn't subscribe user %d to comment thread %s/%d: %s",
|
||||
currentUser.ID, comment.TableName, comment.TableID, err,
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
91
pkg/controller/comment/subscription.go
Normal file
91
pkg/controller/comment/subscription.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package comment
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Subscription endpoint - to opt in or out of comment thread subscriptions.
|
||||
func Subscription() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
tableName = r.FormValue("table_name")
|
||||
tableID uint64
|
||||
subscribe = r.FormValue("subscribe") == "true"
|
||||
fromURL = r.FormValue("next") // what page to send back to
|
||||
)
|
||||
|
||||
// Parse the table ID param.
|
||||
if idStr := r.FormValue("table_id"); idStr == "" {
|
||||
session.FlashError(w, r, "Comment table ID required.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
} else {
|
||||
if idInt, err := strconv.Atoi(idStr); err != nil {
|
||||
session.FlashError(w, r, "Comment table ID invalid.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
} else {
|
||||
tableID = uint64(idInt)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect URL must be relative.
|
||||
if !strings.HasPrefix(fromURL, "/") {
|
||||
// Maybe it's URL encoded?
|
||||
fromURL, _ = url.QueryUnescape(fromURL)
|
||||
if !strings.HasPrefix(fromURL, "/") {
|
||||
fromURL = "/"
|
||||
}
|
||||
}
|
||||
|
||||
// Validate everything else.
|
||||
if _, ok := models.CommentableTables[tableName]; !ok {
|
||||
session.FlashError(w, r, "You can not comment on that.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get current user: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get their subscription.
|
||||
sub, err := models.GetSubscription(currentUser, tableName, tableID)
|
||||
if err != nil {
|
||||
// If they want to subscribe, insert their row.
|
||||
if subscribe {
|
||||
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
|
||||
session.FlashError(w, r, "Couldn't create subscription: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You will now be notified about comments on this page.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Toggle it.
|
||||
sub.Subscribed = subscribe
|
||||
if err := sub.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
|
||||
} else {
|
||||
if subscribe {
|
||||
session.Flash(w, r, "You will now be notified about comments on this page.")
|
||||
} else {
|
||||
session.Flash(w, r, "You will no longer be notified about new comments on this page.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templates.Redirect(w, fromURL)
|
||||
})
|
||||
}
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/markdown"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
|
@ -160,6 +161,31 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Reply added to the thread!")
|
||||
|
||||
// Notify watchers about this new post.
|
||||
for _, userID := range models.GetSubscribers("threads", thread.ID) {
|
||||
if userID == currentUser.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
notif := &models.Notification{
|
||||
UserID: userID,
|
||||
AboutUser: *currentUser,
|
||||
Type: models.NotificationAlsoPosted,
|
||||
TableName: "threads",
|
||||
TableID: thread.ID,
|
||||
Message: message,
|
||||
Link: fmt.Sprintf("/forum/thread/%d", thread.ID),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe the current user to further responses on this thread.
|
||||
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
|
||||
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
|
||||
}
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
|
@ -178,6 +204,12 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't create thread: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Thread created!")
|
||||
|
||||
// Subscribe the current user to responses on this thread.
|
||||
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
|
||||
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
|
||||
}
|
||||
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -74,11 +74,15 @@ func Thread() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"Pager": pager,
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"Pager": pager,
|
||||
"IsSubscribed": isSubscribed,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -76,13 +76,17 @@ func View() http.HandlerFunc {
|
|||
log.Error("Couldn't list comments for photo %d: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||
"User": user,
|
||||
"Photo": photo,
|
||||
"LikeMap": likeMap,
|
||||
"CommentMap": commentMap,
|
||||
"Comments": comments,
|
||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||
"User": user,
|
||||
"Photo": photo,
|
||||
"LikeMap": likeMap,
|
||||
"CommentMap": commentMap,
|
||||
"Comments": comments,
|
||||
"IsSubscribed": isSubscribed,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -22,7 +22,8 @@ type Comment struct {
|
|||
// CommentableTables are the set of table names that allow comments (via the
|
||||
// generic "/comments" URI which accepts a table_name param)
|
||||
var CommentableTables = map[string]interface{}{
|
||||
"photos": nil,
|
||||
"photos": nil,
|
||||
"threads": nil,
|
||||
}
|
||||
|
||||
// Preload related tables for the forum (classmethod).
|
||||
|
|
|
@ -23,6 +23,7 @@ func DeleteUser(user *models.User) error {
|
|||
{"Likes", DeleteLikes},
|
||||
{"Threads", DeleteForumThreads},
|
||||
{"Comments", DeleteComments},
|
||||
{"Subscriptions", DeleteSubscriptions},
|
||||
{"Photos", DeleteUserPhotos},
|
||||
{"Certification Photo", DeleteCertification},
|
||||
{"Messages", DeleteUserMessages},
|
||||
|
@ -126,6 +127,16 @@ func DeleteNotifications(userID uint64) error {
|
|||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteSubscriptions scrubs all notification subscriptions about a user.
|
||||
func DeleteSubscriptions(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteSubscriptions(%d)", userID)
|
||||
result := models.DB.Where(
|
||||
"user_id = ?",
|
||||
userID,
|
||||
).Delete(&models.Subscription{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteLikes scrubs all Likes about a user.
|
||||
func DeleteLikes(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteLikes(%d)", userID)
|
||||
|
|
|
@ -21,4 +21,5 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&Comment{})
|
||||
DB.AutoMigrate(&Like{})
|
||||
DB.AutoMigrate(&Notification{})
|
||||
DB.AutoMigrate(&Subscription{})
|
||||
}
|
||||
|
|
|
@ -34,6 +34,8 @@ const (
|
|||
NotificationLike NotificationType = "like"
|
||||
NotificationFriendApproved = "friendship_approved"
|
||||
NotificationComment = "comment"
|
||||
NotificationAlsoCommented = "also_comment"
|
||||
NotificationAlsoPosted = "also_posted" // forum replies
|
||||
NotificationCertRejected = "cert_rejected"
|
||||
NotificationCertApproved = "cert_approved"
|
||||
NotificationCustom = "custom" // custom message pushed
|
||||
|
@ -104,8 +106,10 @@ func (n *Notification) Save() error {
|
|||
|
||||
// NotificationBody can store remote tables mapped.
|
||||
type NotificationBody struct {
|
||||
PhotoID uint64
|
||||
Photo *Photo
|
||||
PhotoID uint64
|
||||
ThreadID uint64
|
||||
Photo *Photo
|
||||
Thread *Thread
|
||||
}
|
||||
|
||||
type NotificationMap map[uint64]*NotificationBody
|
||||
|
@ -131,6 +135,14 @@ func MapNotifications(ns []*Notification) NotificationMap {
|
|||
result[row.ID] = &NotificationBody{}
|
||||
}
|
||||
|
||||
result.mapNotificationPhotos(IDs)
|
||||
result.mapNotificationThreads(IDs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper function of MapNotifications to eager load Photo attachments.
|
||||
func (nm NotificationMap) mapNotificationPhotos(IDs []uint64) {
|
||||
type scanner struct {
|
||||
PhotoID uint64
|
||||
NotificationID uint64
|
||||
|
@ -157,7 +169,7 @@ func MapNotifications(ns []*Notification) NotificationMap {
|
|||
var photoIDs = []uint64{}
|
||||
for _, row := range scan {
|
||||
// Store the photo ID in the result now.
|
||||
result[row.NotificationID].PhotoID = row.PhotoID
|
||||
nm[row.NotificationID].PhotoID = row.PhotoID
|
||||
photoIDs = append(photoIDs, row.PhotoID)
|
||||
}
|
||||
|
||||
|
@ -167,13 +179,58 @@ func MapNotifications(ns []*Notification) NotificationMap {
|
|||
log.Error("Couldn't load photo IDs for notifications: %s", err)
|
||||
} else {
|
||||
// Marry them to their notification IDs.
|
||||
for _, body := range result {
|
||||
for _, body := range nm {
|
||||
if photo, ok := photos[body.PhotoID]; ok {
|
||||
body.Photo = photo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper function of MapNotifications to eager load Thread attachments.
|
||||
func (nm NotificationMap) mapNotificationThreads(IDs []uint64) {
|
||||
type scanner struct {
|
||||
ThreadID uint64
|
||||
NotificationID uint64
|
||||
}
|
||||
var scan []scanner
|
||||
|
||||
// Load all of these that have threads.
|
||||
err := DB.Table(
|
||||
"notifications",
|
||||
).Joins(
|
||||
"JOIN threads ON (notifications.table_name='threads' AND notifications.table_id=threads.id)",
|
||||
).Select(
|
||||
"threads.id AS thread_id",
|
||||
"notifications.id AS notification_id",
|
||||
).Where(
|
||||
"notifications.id IN ?",
|
||||
IDs,
|
||||
).Scan(&scan)
|
||||
if err.Error != nil {
|
||||
log.Error("Couldn't select thread IDs for notifications: %s", err.Error)
|
||||
}
|
||||
|
||||
// Collect and load all the threads by ID.
|
||||
var threadIDs = []uint64{}
|
||||
for _, row := range scan {
|
||||
// Store the thread ID in the result now.
|
||||
nm[row.NotificationID].ThreadID = row.ThreadID
|
||||
threadIDs = append(threadIDs, row.ThreadID)
|
||||
}
|
||||
|
||||
// Load the threads.
|
||||
if len(threadIDs) > 0 {
|
||||
if threads, err := GetThreads(threadIDs); err != nil {
|
||||
log.Error("Couldn't load thread IDs for notifications: %s", err)
|
||||
} else {
|
||||
// Marry them to their notification IDs.
|
||||
for _, body := range nm {
|
||||
if thread, ok := threads[body.ThreadID]; ok {
|
||||
body.Thread = thread
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
81
pkg/models/subscription.go
Normal file
81
pkg/models/subscription.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
// Subscription table - for notifications. You comment on someone's post, you get subscribed
|
||||
// to other comments added to the post (unless you opt off).
|
||||
type Subscription struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"` // who it belongs to
|
||||
Subscribed bool `gorm:"index"`
|
||||
TableName string // on which of your tables (photos, comments, ...)
|
||||
TableID uint64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetSubscription looks for an existing subscription or returns error if not found.
|
||||
func GetSubscription(user *User, tableName string, tableID uint64) (*Subscription, error) {
|
||||
var s *Subscription
|
||||
result := DB.Model(s).Where(
|
||||
"user_id = ? AND table_name = ? AND table_id = ?",
|
||||
user.ID, tableName, tableID,
|
||||
).First(&s)
|
||||
return s, result.Error
|
||||
}
|
||||
|
||||
// GetSubscribers returns all of the UserIDs that are subscribed to a thread.
|
||||
func GetSubscribers(tableName string, tableID uint64) []uint64 {
|
||||
var userIDs = []uint64{}
|
||||
result := DB.Table(
|
||||
"subscriptions",
|
||||
).Select(
|
||||
"user_id",
|
||||
).Where(
|
||||
"table_name = ? AND table_id = ? AND subscribed IS TRUE",
|
||||
tableName, tableID,
|
||||
).Scan(&userIDs)
|
||||
|
||||
if result.Error != nil {
|
||||
log.Error("GetSubscribers(%s, %d): couldn't get user IDs: %s", tableName, tableID)
|
||||
}
|
||||
|
||||
return userIDs
|
||||
}
|
||||
|
||||
// IsSubscribed checks whether a user is currently subscribed (and notified) to a thing.
|
||||
// Returns whether the row exists, and whether the user is to be notified (false if opted out).
|
||||
func IsSubscribed(user *User, tableName string, tableID uint64) (exists bool, notified bool) {
|
||||
if sub, err := GetSubscription(user, tableName, tableID); err != nil {
|
||||
return false, false
|
||||
} else {
|
||||
return true, sub.Subscribed
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeTo creates a subscription to a thing (comment thread) to be notified of future activity on.
|
||||
func SubscribeTo(user *User, tableName string, tableID uint64) (*Subscription, error) {
|
||||
// Is there already a subscription row?
|
||||
if sub, err := GetSubscription(user, tableName, tableID); err == nil {
|
||||
return sub, err
|
||||
}
|
||||
|
||||
// Create the default subscription.
|
||||
sub := &Subscription{
|
||||
UserID: user.ID,
|
||||
Subscribed: true,
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
}
|
||||
result := DB.Create(sub)
|
||||
return sub, result.Error
|
||||
}
|
||||
|
||||
// Save a subscription.
|
||||
func (n *Subscription) Save() error {
|
||||
return DB.Save(n).Error
|
||||
}
|
|
@ -54,6 +54,7 @@ func New() http.Handler {
|
|||
mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser()))
|
||||
mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked()))
|
||||
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
|
||||
mux.Handle("/comments/subscription", middleware.LoginRequired(comment.Subscription()))
|
||||
mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
|
||||
|
||||
// Certification Required. Pages that only full (verified) members can access.
|
||||
|
|
|
@ -218,6 +218,26 @@
|
|||
{{end}}
|
||||
</a>
|
||||
</span>
|
||||
{{else if eq .Type "also_comment"}}
|
||||
<span class="icon"><i class="fa fa-comment has-text-success"></i></span>
|
||||
<span>
|
||||
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
|
||||
also commented on a
|
||||
<a href="{{.Link}}">
|
||||
{{if eq .TableName "photos"}}
|
||||
photo
|
||||
{{else}}
|
||||
{{.TableName}}
|
||||
{{end}}
|
||||
</a>
|
||||
that you replied to:
|
||||
</span>
|
||||
{{else if eq .Type "also_posted"}}
|
||||
<span class="icon"><i class="fa fa-comments has-text-success"></i></span>
|
||||
<span>
|
||||
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
|
||||
replied to <a href="{{.Link}}">a forum thread</a> that you follow:
|
||||
</span>
|
||||
{{else if eq .Type "friendship_approved"}}
|
||||
<span class="icon"><i class="fa fa-user-group has-text-success"></i></span>
|
||||
<span>
|
||||
|
@ -242,7 +262,14 @@
|
|||
<!-- Attached message? -->
|
||||
{{if .Message}}
|
||||
<div class="block content mb-1">
|
||||
{{ToMarkdown .Message}}
|
||||
<blockquote class="p-2 pl-4">{{ToMarkdown (TrimEllipses .Message 256)}}</blockquote>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Attached forum thread? -->
|
||||
{{if $Body.Thread}}
|
||||
<div>
|
||||
On thread: <a href="{{.Link}}">{{$Body.Thread.Title}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -71,11 +71,24 @@
|
|||
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
|
||||
</div>
|
||||
|
||||
<p class="block p-4">
|
||||
<p class="block p-4 mb-0">
|
||||
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this thread
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</p>
|
||||
|
||||
<p class="block px-4 mb-4">
|
||||
<a href="/comments/subscription?table_name=threads&table_id={{.Thread.ID}}&next={{UrlEncode .Request.URL.String}}&subscribe={{if not .IsSubscribed}}true{{else}}false{{end}}">
|
||||
<span class="icon"><i class="fa fa-bell{{if not .IsSubscribed}}-slash{{end}}"></i></span>
|
||||
<span>
|
||||
{{if .IsSubscribed}}
|
||||
Disable notifications about this thread
|
||||
{{else}}
|
||||
Enable notifications about this thread
|
||||
{{end}}
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<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"
|
||||
|
|
|
@ -44,19 +44,6 @@
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<h1>Open Beta</h1>
|
||||
|
||||
<p>
|
||||
This website is currently open for beta testing. It's not 100% complete yet, but is basically
|
||||
functional. It currently supports profile pages, photo galleries, friend requests, Direct
|
||||
Messages, a member directory to discover new users, and basic account management features.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Before the "1.0" launch it will include web forums which will provide a community space to
|
||||
chat with and meet other members.
|
||||
</p>
|
||||
|
||||
<h1>Site Rules</h1>
|
||||
|
||||
<ul>
|
||||
|
|
|
@ -183,6 +183,21 @@
|
|||
|
||||
<hr class="is-dark">
|
||||
|
||||
{{if not .IsOwnPhoto}}
|
||||
<p class="mb-4">
|
||||
<a href="/comments/subscription?table_name=photos&table_id={{.Photo.ID}}&next={{UrlEncode .Request.URL.String}}&subscribe={{if not .IsSubscribed}}true{{else}}false{{end}}">
|
||||
<span class="icon"><i class="fa fa-bell{{if not .IsSubscribed}}-slash{{end}}"></i></span>
|
||||
<span>
|
||||
{{if .IsSubscribed}}
|
||||
Disable notifications about this comment thread
|
||||
{{else}}
|
||||
Enable notifications about this comment thread
|
||||
{{end}}
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
{{if eq $Comments 0}}
|
||||
<p>
|
||||
<em>There are no comments yet.</em>
|
||||
|
|
Loading…
Reference in New Issue
Block a user