diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 116b976..2a01ff9 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -82,9 +82,18 @@ var ( {"report.user", "Report a problematic user"}, {"report.photo", "Report a problematic photo"}, {"report.message", "Report a direct message conversation"}, + {"report.comment", "Report a forum post or comment"}, }, }, } + + // Default forum categories for forum landing page. + ForumCategories = []string{ + "Rules and Announcements", + "Nudists", + "Exhibitionists", + "Anything Goes", + } ) // ContactUs choices for the subject drop-down. diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index df1c39d..9ea120d 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -2,13 +2,17 @@ package config // Pagination sizes per page. var ( - PageSizeMemberSearch = 60 - PageSizeFriends = 12 - PageSizeBlockList = 12 - PageSizeAdminCertification = 20 - PageSizeAdminFeedback = 20 - PageSizeSiteGallery = 18 - PageSizeUserGallery = 18 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 20 // conversation view + PageSizeMemberSearch = 60 + PageSizeFriends = 12 + PageSizeBlockList = 12 + PageSizeAdminCertification = 20 + PageSizeAdminFeedback = 20 + PageSizeSiteGallery = 18 + PageSizeUserGallery = 18 + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view + PageSizeForums = 100 // TODO: for main category index view + PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread + PageSizeForumAdmin = 20 + PageSizeDashboardNotifications = 50 ) diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index d529ac9..a2d8592 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -3,6 +3,9 @@ package account import ( "net/http" + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -10,7 +13,41 @@ import ( func Dashboard() http.HandlerFunc { tmpl := templates.Must("account/dashboard.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := tmpl.Execute(w, r, nil); err != nil { + currentUser, err := session.CurrentUser(r) + if err != nil { + http.Error(w, "Couldn't get currentUser", http.StatusInternalServerError) + return + } + + // Mark all notifications read? + if r.FormValue("intent") == "read-notifications" { + models.MarkNotificationsRead(currentUser) + session.Flash(w, r, "All of your notifications have been marked as 'read!'") + templates.Redirect(w, "/me") + return + } + + // Get our notifications. + pager := &models.Pagination{ + Page: 1, + PerPage: config.PageSizeDashboardNotifications, + Sort: "created_at desc", + } + pager.ParsePage(r) + notifs, err := models.PaginateNotifications(currentUser, pager) + if err != nil { + session.FlashError(w, r, "Couldn't get your notifications: %s", err) + } + + // Map our notifications. + notifMap := models.MapNotifications(notifs) + + var vars = map[string]interface{}{ + "Notifications": notifs, + "NotifMap": notifMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 0cb52d9..3082b99 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -57,8 +57,12 @@ func Profile() http.HandlerFunc { isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved" ) + // Get Likes for this profile. + likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID}) + vars := map[string]interface{}{ "User": user, + "LikeMap": likeMap, "IsFriend": isFriend, "IsPrivate": isPrivate, } diff --git a/pkg/controller/admin/feedback.go b/pkg/controller/admin/feedback.go index f0fe354..b1b5604 100644 --- a/pkg/controller/admin/feedback.go +++ b/pkg/controller/admin/feedback.go @@ -89,6 +89,20 @@ func Feedback() http.HandlerFunc { return } } + case "comments": + // Get this comment. + comment, err := models.GetComment(fb.TableID) + if err != nil { + session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err) + } else { + // What was the comment on? + switch comment.TableName { + case "threads": + // Visit the thread. + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID)) + return + } + } default: session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID) } diff --git a/pkg/controller/api/json_layer.go b/pkg/controller/api/json_layer.go new file mode 100644 index 0000000..ca65423 --- /dev/null +++ b/pkg/controller/api/json_layer.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Envelope is the standard JSON response envelope. +type Envelope struct { + Data interface{} `json:"data"` + StatusCode int +} + +// ParseJSON request body. +func ParseJSON(r *http.Request, v interface{}) error { + if r.Header.Get("Content-Type") != "application/json" { + return errors.New("request Content-Type must be application/json") + } + + // Parse request body. + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + + log.Error("body: %+v", body) + + // Parse params from JSON. + if err := json.Unmarshal(body, v); err != nil { + return err + } + + return nil +} + +// SendJSON response. +func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) { + buf, err := json.Marshal(Envelope{ + Data: v, + StatusCode: statusCode, + }) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(buf) +} diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go new file mode 100644 index 0000000..c7333b0 --- /dev/null +++ b/pkg/controller/api/likes.go @@ -0,0 +1,124 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// Likes API. +func Likes() http.HandlerFunc { + // Request JSON schema. + type Request struct { + TableName string `json:"name"` + TableID uint64 `json:"id"` + Unlike bool `json:"unlike,omitempty"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + Likes int64 `json:"likes"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Who do we notify about this like? + var targetUser *models.User + switch req.TableName { + case "photos": + if photo, err := models.GetPhoto(req.TableID); err == nil { + if user, err := models.GetUser(photo.UserID); err == nil { + targetUser = user + } + } else { + log.Error("For like on photos table: didn't find photo %d: %s", req.TableID, err) + } + case "users": + log.Error("subject is users, find %d", req.TableID) + if user, err := models.GetUser(req.TableID); err == nil { + targetUser = user + log.Warn("found user %s", targetUser.Username) + } else { + log.Error("For like on users table: didn't find user %d: %s", req.TableID, err) + } + } + + // Is the table likeable? + if _, ok := models.LikeableTables[req.TableName]; !ok { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Can't like table %s: not allowed.", req.TableName), + }) + return + } + + // Put in a like. + if req.Unlike { + if err := models.Unlike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error unliking: %s", err), + }) + return + } + + // Remove the target's notification about this like. + models.RemoveNotification(req.TableName, req.TableID) + } else { + if err := models.AddLike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error liking: %s", err), + }) + return + } + + // Notify the recipient of the like. + log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, req.TableID, targetUser) + if targetUser != nil { + notif := &models.Notification{ + UserID: targetUser.ID, + User: *currentUser, + Type: models.NotificationLike, + TableName: req.TableName, + TableID: req.TableID, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create Likes notification: %s", err) + } + } + } + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + Likes: models.CountLikes(req.TableName, req.TableID), + }) + }) +} diff --git a/pkg/controller/api/read_notification.go b/pkg/controller/api/read_notification.go new file mode 100644 index 0000000..3a34edc --- /dev/null +++ b/pkg/controller/api/read_notification.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// ReadNotification API to mark a notif ID as "read." +func ReadNotification() http.HandlerFunc { + // Request JSON schema. + type Request struct { + ID uint64 `json:"id"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Get this notification. + notif, err := models.GetNotification(req.ID) + if err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + Error: err.Error(), + }) + return + } + + // Ensure it's ours to read. + if notif.UserID != currentUser.ID { + SendJSON(w, http.StatusForbidden, Response{ + Error: "That is not your notification.", + }) + return + } + + // Mark it read. + notif.Read = true + notif.Save() + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + }) + }) +} diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go new file mode 100644 index 0000000..8dcf5be --- /dev/null +++ b/pkg/controller/forum/add_edit.go @@ -0,0 +1,139 @@ +package forum + +import ( + "net/http" + "strconv" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// AddEdit page. +func AddEdit() http.HandlerFunc { + tmpl := templates.Must("forum/add_edit.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Are we editing a forum or creating a new one? + var editID uint64 + if editStr := r.FormValue("id"); editStr != "" { + if i, err := strconv.Atoi(editStr); err == nil { + editID = uint64(i) + } else { + session.FlashError(w, r, "Edit parameter: id was not an integer") + templates.Redirect(w, "/forum/admin") + 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 + } + + // If editing, look up the existing forum. + var forum *models.Forum + if editID > 0 { + if found, err := models.GetForum(editID); err != nil { + session.FlashError(w, r, "Couldn't get forum: %s", err) + templates.Redirect(w, "/forum/admin") + return + } else { + // Do we have permission? + if found.OwnerID != currentUser.ID && !currentUser.IsAdmin { + templates.ForbiddenPage(w, r) + return + } + + forum = found + } + } + + // Saving? + if r.Method == http.MethodPost { + var ( + title = strings.TrimSpace(r.PostFormValue("title")) + fragment = strings.TrimSpace(strings.ToLower(r.PostFormValue("fragment"))) + description = strings.TrimSpace(r.PostFormValue("description")) + category = strings.TrimSpace(r.PostFormValue("category")) + isExplicit = r.PostFormValue("explicit") == "true" + isPrivileged = r.PostFormValue("privileged") == "true" + isPermitPhotos = r.PostFormValue("permit_photos") == "true" + ) + + // Sanity check admin-only settings. + if !currentUser.IsAdmin { + isPrivileged = false + isPermitPhotos = false + } + + // Were we editing an existing forum? + if forum != nil { + forum.Title = title + forum.Description = description + forum.Category = category + forum.Explicit = isExplicit + forum.Privileged = isPrivileged + forum.PermitPhotos = isPermitPhotos + + // Save it. + if err := forum.Save(); err == nil { + session.Flash(w, r, "Forum has been updated!") + templates.Redirect(w, "/forum/admin") + return + } else { + session.FlashError(w, r, "Error saving the forum: %s", err) + } + } else { + // Validate the fragment. Front-end enforces the pattern so this + // is just a sanity check. + if m := FragmentRegexp.FindStringSubmatch(fragment); m == nil { + session.FlashError(w, r, "The fragment format is invalid.") + templates.Redirect(w, "/forum/admin") + return + } + + // Ensure the fragment is unique. + if _, err := models.ForumByFragment(fragment); err == nil { + session.FlashError(w, r, "The forum fragment is already in use.") + } else { + // Create the forum. + forum = &models.Forum{ + Owner: *currentUser, + Category: category, + Fragment: fragment, + Title: title, + Description: description, + Explicit: isExplicit, + Privileged: isPrivileged, + PermitPhotos: isPermitPhotos, + } + + if err := models.CreateForum(forum); err == nil { + session.Flash(w, r, "The forum has been created!") + templates.Redirect(w, "/forum/admin") + return + } else { + session.FlashError(w, r, "Error creating the forum: %s", err) + } + } + } + } + + _ = editID + + var vars = map[string]interface{}{ + "EditID": editID, + "EditForum": forum, + "Categories": config.ForumCategories, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go new file mode 100644 index 0000000..f77cd16 --- /dev/null +++ b/pkg/controller/forum/forum.go @@ -0,0 +1,85 @@ +package forum + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Forum view for a specific board index. +func Forum() http.HandlerFunc { + tmpl := templates.Must("forum/board_index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path parameters + var ( + forum *models.Forum + ) + + if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil { + log.Error("Regexp failed to parse: %s", r.URL.Path) + templates.NotFoundPage(w, r) + return + } else { + // Look up the forum itself. + if found, err := models.ForumByFragment(m[1]); err != nil { + templates.NotFoundPage(w, r) + return + } else { + forum = found + } + } + + // 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 the pinned threads. + pinned, err := models.PinnedThreads(forum) + if err != nil { + session.FlashError(w, r, "Couldn't get pinned threads: %s", err) + templates.Redirect(w, "/") + return + } + + // Get all the categorized index forums. + // XXX: we get a large page size to get ALL official forums + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeThreadList, + Sort: "updated_at desc", + } + pager.ParsePage(r) + + threads, err := models.PaginateThreads(currentUser, forum, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate threads: %s", err) + templates.Redirect(w, "/") + return + } + + // Inject pinned threads on top. + threads = append(pinned, threads...) + + // Map the statistics (replies, views) of these threads. + threadMap := models.MapThreadStatistics(threads) + + var vars = map[string]interface{}{ + "Forum": forum, + "Threads": threads, + "ThreadMap": threadMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go new file mode 100644 index 0000000..2987040 --- /dev/null +++ b/pkg/controller/forum/forums.go @@ -0,0 +1,77 @@ +package forum + +import ( + "fmt" + "net/http" + "regexp" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Regular expressions +var ( + FragmentPattern = `[a-z0-9._-]{1,30}` + FragmentRegexp = regexp.MustCompile( + fmt.Sprintf(`^(%s)$`, FragmentPattern), + ) + + // Forum path parameters. + ForumPathRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)`, FragmentPattern), + ) + ForumPostRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern), + ) + ForumThreadRegexp = regexp.MustCompile( + fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern), + ) +) + +// Landing page for forums. +func Landing() http.HandlerFunc { + tmpl := templates.Must("forum/index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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 all the categorized index forums. + // XXX: we get a large page size to get ALL official forums + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeForums, + Sort: "title asc", + } + pager.ParsePage(r) + + forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate forums: %s", err) + templates.Redirect(w, "/") + return + } + + // Bucket the forums into their categories for easy front-end. + categorized := models.CategorizeForums(forums, config.ForumCategories) + + // Map statistics for these forums. + forumMap := models.MapForumStatistics(forums) + + var vars = map[string]interface{}{ + "Pager": pager, + "Categories": categorized, + "ForumMap": forumMap, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/manage.go b/pkg/controller/forum/manage.go new file mode 100644 index 0000000..7401105 --- /dev/null +++ b/pkg/controller/forum/manage.go @@ -0,0 +1,48 @@ +package forum + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Manage page for forums -- admin only for now but may open up later. +func Manage() http.HandlerFunc { + tmpl := templates.Must("forum/admin.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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 forums the user owns or can manage. + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeForumAdmin, + Sort: "updated_at desc", + } + pager.ParsePage(r) + + forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate owned forums: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "Pager": pager, + "Forums": forums, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go new file mode 100644 index 0000000..9589a82 --- /dev/null +++ b/pkg/controller/forum/new_post.go @@ -0,0 +1,206 @@ +package forum + +import ( + "fmt" + "net/http" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/markdown" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// NewPost view. +func NewPost() http.HandlerFunc { + tmpl := templates.Must("forum/new_post.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + fragment = r.FormValue("to") // forum to (new post) + toThreadID = r.FormValue("thread") // add reply to a thread ID + quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment + editCommentID = r.FormValue("edit") // edit your comment + intent = r.FormValue("intent") // preview or submit + title = r.FormValue("title") // for new forum post only + message = r.PostFormValue("message") // comment body + isPinned = r.PostFormValue("pinned") == "true" // owners or admins only + isExplicit = r.PostFormValue("explicit") == "true" // for thread only + isNoReply = r.PostFormValue("noreply") == "true" // for thread only + isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id) + forum *models.Forum + thread *models.Thread // if replying to a thread + comment *models.Comment // if editing a comment + + // If we are modifying a comment (post) and it's the OG post of the + // thread, we show and accept the thread settings to be updated as + // well (pinned, explicit, noreply) + isOriginalComment bool + ) + + // 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 + } + + // Look up the forum itself. + if found, err := models.ForumByFragment(fragment); err != nil { + session.FlashError(w, r, "Couldn't post to forum %s: not found.", fragment) + templates.Redirect(w, "/forum") + return + } else { + forum = found + } + + // Are we manipulating a reply to an existing thread? + if len(toThreadID) > 0 { + if i, err := strconv.Atoi(toThreadID); err == nil { + if found, err := models.GetThread(uint64(i)); err != nil { + session.FlashError(w, r, "Couldn't find that thread ID!") + templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment)) + return + } else { + thread = found + } + } + } + + // Are we pre-filling the message with a quotation of an existing comment? + if len(quoteCommentID) > 0 { + if i, err := strconv.Atoi(quoteCommentID); err == nil { + if comment, err := models.GetComment(uint64(i)); err == nil { + message = markdown.Quotify(comment.Message) + "\n\n" + } + } + } + + // Are we editing or deleting our comment? + if len(editCommentID) > 0 { + if i, err := strconv.Atoi(editCommentID); err == nil { + if found, err := models.GetComment(uint64(i)); err == nil { + comment = found + + // Verify that it is indeed OUR comment. + if currentUser.ID != comment.UserID && !currentUser.IsAdmin { + templates.ForbiddenPage(w, r) + return + } + + // Initialize the form w/ the content of this message. + if r.Method == http.MethodGet { + message = comment.Message + } + + // Is this the OG thread of the post? + if thread.CommentID == comment.ID { + isOriginalComment = true + + // Restore the checkbox option form values from thread settings. + if r.Method == http.MethodGet { + isPinned = thread.Pinned + isExplicit = thread.Explicit + isNoReply = thread.NoReply + } + } + + // Are we DELETING this comment? + if isDelete { + if err := thread.DeleteReply(comment); err != nil { + session.FlashError(w, r, "Error deleting your post: %s", err) + } else { + session.Flash(w, r, "Your post has been deleted.") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + } else { + // Comment not found - show the Forbidden page anyway. + templates.ForbiddenPage(w, r) + return + } + } else { + templates.NotFoundPage(w, r) + return + } + } + + // Submitting the form. + if r.Method == http.MethodPost { + // Default intent is preview unless told to submit. + if intent == "submit" { + // Are we modifying an existing comment? + if comment != nil { + comment.Message = message + + // Can we update the thread props? + if isOriginalComment { + thread.Pinned = isPinned + thread.Explicit = isExplicit + thread.NoReply = isNoReply + if err := thread.Save(); err != nil { + session.FlashError(w, r, "Couldn't save thread properties: %s", err) + } + } + + if err := comment.Save(); err != nil { + session.FlashError(w, r, "Couldn't save comment: %s", err) + } else { + session.Flash(w, r, "Comment updated!") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + + // Are we replying to an existing thread? + if thread != nil { + if _, err := thread.Reply(currentUser, message); err != nil { + session.FlashError(w, r, "Couldn't add reply to thread: %s", err) + } else { + session.Flash(w, r, "Reply added to the thread!") + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + + // Create a new thread? + if thread, err := models.CreateThread( + currentUser, + forum.ID, + title, + message, + isPinned, + isExplicit, + isNoReply, + ); err != nil { + session.FlashError(w, r, "Couldn't create thread: %s", err) + } else { + session.Flash(w, r, "Thread created!") + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) + return + } + } + } + + var vars = map[string]interface{}{ + "Forum": forum, + "Thread": thread, + "Intent": intent, + "PostTitle": title, + "EditCommentID": editCommentID, + "EditThreadSettings": isOriginalComment, + "Message": message, + + // Thread settings (for editing the original comment esp.) + "IsPinned": isPinned, + "IsExplicit": isExplicit, + "IsNoReply": isNoReply, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go new file mode 100644 index 0000000..f822402 --- /dev/null +++ b/pkg/controller/forum/thread.go @@ -0,0 +1,88 @@ +package forum + +import ( + "net/http" + "regexp" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`) + +// Thread view for a specific board index. +func Thread() http.HandlerFunc { + tmpl := templates.Must("forum/thread.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path parameters + var ( + forum *models.Forum + thread *models.Thread + ) + + if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil { + log.Error("Regexp failed to parse: %s", r.URL.Path) + templates.NotFoundPage(w, r) + return + } else { + if threadID, err := strconv.Atoi(m[1]); err != nil { + session.FlashError(w, r, "Invalid thread ID in the address bar.") + templates.Redirect(w, "/forum") + return + } else { + // Load the thread. + if found, err := models.GetThread(uint64(threadID)); err != nil { + session.FlashError(w, r, "That thread does not exist.") + templates.Redirect(w, "/forum") + return + } else { + thread = found + forum = &thread.Forum + } + } + } + + // 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 + } + + // Ping the view count on this thread. + if err := thread.View(); err != nil { + log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err) + } + + // Paginate the comments on this thread. + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeThreadList, + Sort: "created_at asc", + } + pager.ParsePage(r) + + comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate comments: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "Forum": forum, + "Thread": thread, + "Comments": comments, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go index e13ca24..1769ff0 100644 --- a/pkg/controller/index/contact.go +++ b/pkg/controller/index/contact.go @@ -65,6 +65,15 @@ func Contact() http.HandlerFunc { case "report.message": tableName = "messages" tableLabel = "Direct Message conversation" + case "report.comment": + tableName = "comments" + + // Find this comment. + if comment, err := models.GetComment(uint64(tableID)); err == nil { + tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username) + } else { + log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err) + } } } diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 8593627..c99e0e5 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -46,10 +46,18 @@ func SiteGallery() http.HandlerFunc { session.FlashError(w, r, "Failed to MapUsers: %s", err) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsSiteGallery": true, "Photos": photos, "UserMap": userMap, + "LikeMap": likeMap, "Pager": pager, "ViewStyle": viewStyle, } diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 5e261e3..3738454 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -91,11 +91,19 @@ func UserPhotos() http.HandlerFunc { explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsOwnPhotos": currentUser.ID == user.ID, "User": user, "Photos": photos, "Pager": pager, + "LikeMap": likeMap, "ViewStyle": viewStyle, "ExplicitCount": explicitCount, } diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index eaa69fc..31dede1 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -2,6 +2,8 @@ package markdown import ( + "strings" + "github.com/microcosm-cc/bluemonday" "github.com/shurcooL/github_flavored_markdown" ) @@ -16,3 +18,12 @@ func Render(input string) string { safened := p.SanitizeBytes(html) return string(safened) } + +// Quotify a message putting it into a Markdown "> quotes" block. +func Quotify(input string) string { + var lines = []string{} + for _, line := range strings.Split(input, "\n") { + lines = append(lines, "> "+line) + } + return strings.Join(lines, "\n") +} diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index ef26bf3..7f6a680 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -18,6 +18,12 @@ func CSRF(handler http.Handler) http.Handler { token := MakeCSRFCookie(r, w) ctx := context.WithValue(r.Context(), session.CSRFKey, token) + // If it's a JSON post, allow it thru. + if r.Header.Get("Content-Type") == "application/json" { + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } + // If we are running a POST request, validate the CSRF form value. if r.Method != http.MethodGet { r.ParseMultipartForm(config.MultipartMaxMemory) diff --git a/pkg/models/comment.go b/pkg/models/comment.go new file mode 100644 index 0000000..4197fd7 --- /dev/null +++ b/pkg/models/comment.go @@ -0,0 +1,70 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Comment table - in forum threads, on profiles or photos, etc. +type Comment struct { + ID uint64 `gorm:"primaryKey"` + TableName string `gorm:"index"` + TableID uint64 `gorm:"index"` + UserID uint64 `gorm:"index"` + User User + Message string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (c *Comment) Preload() *gorm.DB { + return DB.Preload("User.ProfilePhoto") +} + +// GetComment by ID. +func GetComment(id uint64) (*Comment, error) { + c := &Comment{} + result := c.Preload().First(&c, id) + return c, result.Error +} + +// AddComment about anything. +func AddComment(user *User, tableName string, tableID uint64, message string) (*Comment, error) { + c := &Comment{ + TableName: tableName, + TableID: tableID, + User: *user, + Message: message, + } + result := DB.Create(c) + return c, result.Error +} + +// PaginateComments provides a page of comments on something. +func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) { + var ( + cs = []*Comment{} + query = (&Comment{}).Preload() + ) + + query = query.Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Order(pager.Sort) + + query.Model(&Comment{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs) + return cs, result.Error +} + +// Save a comment. +func (c *Comment) Save() error { + return DB.Save(c).Error +} + +// Delete a comment. +func (c *Comment) Delete() error { + return DB.Delete(c).Error +} diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 10dd7a4..a08ad3f 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -19,6 +19,10 @@ func DeleteUser(user *models.User) error { } var todo = []remover{ + {"Notifications", DeleteNotifications}, + {"Likes", DeleteLikes}, + {"Threads", DeleteForumThreads}, + {"Comments", DeleteComments}, {"Photos", DeleteUserPhotos}, {"Certification Photo", DeleteCertification}, {"Messages", DeleteUserMessages}, @@ -112,6 +116,26 @@ func DeleteFriends(userID uint64) error { return result.Error } +// DeleteNotifications scrubs all notifications about a user. +func DeleteNotifications(userID uint64) error { + log.Error("DeleteUser: DeleteNotifications(%d)", userID) + result := models.DB.Where( + "user_id = ? OR about_user_id = ?", + userID, userID, + ).Delete(&models.Notification{}) + return result.Error +} + +// DeleteLikes scrubs all Likes about a user. +func DeleteLikes(userID uint64) error { + log.Error("DeleteUser: DeleteLikes(%d)", userID) + result := models.DB.Where( + "user_id = ? OR (table_name='users' AND table_id=?)", + userID, userID, + ).Delete(&models.Like{}) + return result.Error +} + // DeleteProfile scrubs data for deleting a user. func DeleteProfile(userID uint64) error { log.Error("DeleteUser: DeleteProfile(%d)", userID) @@ -121,3 +145,57 @@ func DeleteProfile(userID uint64) error { ).Delete(&models.ProfileField{}) return result.Error } + +// DeleteForumThreads scrubs all forum threads started by the user. +func DeleteForumThreads(userID uint64) error { + log.Error("DeleteUser: DeleteForumThreads(%d)", userID) + + var threadIDs = []uint64{} + result := models.DB.Table( + "threads", + ).Joins( + "JOIN comments ON (threads.comment_id = comments.id)", + ).Select( + "distinct(threads.id) as id", + ).Where( + "comments.user_id = ?", + userID, + ).Scan(&threadIDs) + + if result.Error != nil { + return fmt.Errorf("Couldn't list thread IDs created by user: %s", result.Error) + } + + log.Warn("thread IDs to wipe: %+v", threadIDs) + + // Wipe all these threads and their comments. + if len(threadIDs) > 0 { + result = models.DB.Where( + "table_name = ? AND table_id IN ?", + "threads", threadIDs, + ).Delete(&models.Comment{}) + if result.Error != nil { + return fmt.Errorf("Couldn't wipe threads of comments: %s", result.Error) + } + + // And finish the threads off too. + result = models.DB.Where( + "id IN ?", + threadIDs, + ).Delete(&models.Thread{}) + return result.Error + } + + return nil +} + +// DeleteComments deletes all comments by the user. +func DeleteComments(userID uint64) error { + log.Error("DeleteUser: DeleteComments(%d)", userID) + + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.Comment{}) + return result.Error +} diff --git a/pkg/models/forum.go b/pkg/models/forum.go new file mode 100644 index 0000000..cd44c46 --- /dev/null +++ b/pkg/models/forum.go @@ -0,0 +1,161 @@ +package models + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +// Forum table. +type Forum struct { + ID uint64 `gorm:"primaryKey"` + OwnerID uint64 `gorm:"index"` + Owner User `gorm:"foreignKey:owner_id"` + Category string `gorm:"index"` + Fragment string `gorm:"uniqueIndex"` + Title string + Description string + Explicit bool `gorm:"index"` + Privileged bool + PermitPhotos bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (f *Forum) Preload() *gorm.DB { + return DB.Preload("Owner") +} + +// GetForum by ID. +func GetForum(id uint64) (*Forum, error) { + forum := &Forum{} + result := forum.Preload().First(&forum, id) + return forum, result.Error +} + +// ForumByFragment looks up a forum by its URL fragment. +func ForumByFragment(fragment string) (*Forum, error) { + if fragment == "" { + return nil, errors.New("the URL fragment is required") + } + + var ( + f = &Forum{} + result = f.Preload().Where( + "fragment = ?", + fragment, + ).First(&f) + ) + + return f, result.Error +} + +/* +PaginateForums scans over the available forums for a user. + +Parameters: + +- userID: of who is looking +- categories: optional, filter within categories +- pager +*/ +func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) { + var ( + fs = []*Forum{} + query = (&Forum{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + if categories != nil && len(categories) > 0 { + wheres = append(wheres, "category IN ?") + placeholders = append(placeholders, categories) + } + + // Hide explicit forum if user hasn't opted into it. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit = false") + } + + // Filters? + if len(wheres) > 0 { + query = query.Where( + strings.Join(wheres, " AND "), + placeholders..., + ) + } + + query = query.Order(pager.Sort) + query.Model(&Forum{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + return fs, result.Error +} + +// PaginateOwnedForums returns forums the user owns (or all forums to admins). +func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) { + var ( + fs = []*Forum{} + query = (&Forum{}).Preload() + ) + + if !isAdmin { + query = query.Where( + "owner_id = ?", + userID, + ) + } + + query = query.Order(pager.Sort) + query.Model(&Forum{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + return fs, result.Error +} + +// CreateForum. +func CreateForum(f *Forum) error { + result := DB.Create(f) + return result.Error +} + +// Save a forum. +func (f *Forum) Save() error { + return DB.Save(f).Error +} + +// CategorizedForum supports the main index page with custom categories. +type CategorizedForum struct { + Category string + Forums []*Forum +} + +// CategorizeForums buckets forums into categories for front-end. +func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum { + var ( + result = []*CategorizedForum{} + idxMap = map[string]int{} + ) + + // Initialize the result set. + for i, category := range categories { + result = append(result, &CategorizedForum{ + Category: category, + Forums: []*Forum{}, + }) + idxMap[category] = i + } + + // Bucket the forums into their categories. + for _, forum := range fs { + category := forum.Category + if category == "" { + continue + } + idx := idxMap[category] + result[idx].Forums = append(result[idx].Forums, forum) + } + + return result +} diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go new file mode 100644 index 0000000..8985ecc --- /dev/null +++ b/pkg/models/forum_stats.go @@ -0,0 +1,203 @@ +package models + +import "git.kirsle.net/apps/gosocial/pkg/log" + +// ForumStatistics queries for forum-level statistics. +type ForumStatistics struct { + RecentThread *Thread + Threads uint64 + Posts uint64 + Users uint64 +} + +type ForumStatsMap map[uint64]*ForumStatistics + +// MapForumStatistics looks up statistics for a set of forums. +func MapForumStatistics(forums []*Forum) ForumStatsMap { + var ( + result = ForumStatsMap{} + IDs = []uint64{} + ) + + // Collect forum IDs and initialize the map. + for _, forum := range forums { + IDs = append(IDs, forum.ID) + result[forum.ID] = &ForumStatistics{} + } + + // Gather all the statistics. + result.generateThreadCount(IDs) + result.generatePostCount(IDs) + result.generateUserCount(IDs) + result.generateRecentThreads(IDs) + + return result +} + +// Has stats for this thread? (we should..) +func (ts ForumStatsMap) Has(threadID uint64) bool { + _, ok := ts[threadID] + return ok +} + +// Get thread stats. +func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics { + if stats, ok := ts[threadID]; ok { + return stats + } + return nil +} + +// Compute the count of threads in each of the forum 'IDs'. +func (ts ForumStatsMap) generateThreadCount(IDs []uint64) { + // Hold the result of the count/group by query. + type group struct { + ID uint64 + Threads uint64 + } + var groups = []group{} + + // Count comments grouped by thread IDs. + err := DB.Table( + "threads", + ).Select( + "forum_id AS id, count(id) AS threads", + ).Where( + "forum_id IN ?", + IDs, + ).Group("forum_id").Scan(&groups) + + if err.Error != nil { + log.Error("MapForumStatistics: SQL error: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ID]; ok { + stats.Threads = row.Threads + } + } +} + +// Compute the count of all posts in each of the forum 'IDs'. +func (ts ForumStatsMap) generatePostCount(IDs []uint64) { + type group struct { + ID uint64 + Posts uint64 + } + var groups = []group{} + + err := DB.Table( + "comments", + ).Joins( + "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "JOIN forums ON (threads.forum_id = forums.id)", + ).Select( + "forums.id AS id, count(comments.id) AS posts", + ).Where( + `table_name = 'threads' AND EXISTS ( + SELECT 1 + FROM threads + WHERE table_id = threads.id + AND threads.forum_id IN ? + )`, + IDs, + ).Group("forums.id").Scan(&groups) + + if err.Error != nil { + log.Error("SQL error collecting posts for forum: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ID]; ok { + stats.Posts = row.Posts + } + } +} + +// Compute the count of all users in each of the forum 'IDs'. +func (ts ForumStatsMap) generateUserCount(IDs []uint64) { + type group struct { + ForumID uint64 + Users uint64 + } + var groups = []group{} + + err := DB.Table( + "comments", + ).Joins( + "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "JOIN forums ON (threads.forum_id = forums.id)", + ).Select( + "forums.id AS forum_id, count(distinct(comments.user_id)) AS users", + ).Where( + "forums.id IN ?", + IDs, + ).Group("forums.id").Scan(&groups) + + if err.Error != nil { + log.Error("SQL error collecting users for forum: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ForumID]; ok { + stats.Users = row.Users + } + } +} + +// Compute the recent threads for each of the forum 'IDs'. +func (ts ForumStatsMap) generateRecentThreads(IDs []uint64) { + var threadIDs = []map[string]interface{}{} + err := DB.Table( + "threads", + ).Select( + "forum_id, id AS thread_id, updated_at", + ).Where( + `updated_at = (SELECT MAX(updated_at) + FROM threads t2 + WHERE threads.forum_id = t2.forum_id) + AND threads.forum_id IN ?`, + IDs, + ).Order( + "updated_at desc", + ).Scan(&threadIDs) + + if err.Error != nil { + log.Error("Getting most recent thread IDs: %s", err.Error) + } + + // Map them easier. + var ( + threadForumMap = map[uint64]uint64{} + allThreadIDs = []uint64{} + ) + for _, row := range threadIDs { + if row["thread_id"] == nil || row["forum_id"] == nil { + continue + } + + var ( + threadID = uint64(row["thread_id"].(int64)) + forumID = uint64(row["forum_id"].(int64)) + ) + + allThreadIDs = append(allThreadIDs, threadID) + threadForumMap[threadID] = forumID + } + + // Select and map these threads in. + if threadMap, err := GetThreads(allThreadIDs); err == nil { + for threadID, thread := range threadMap { + if forumID, ok := threadForumMap[threadID]; ok { + if stats, ok := ts[forumID]; ok { + stats.RecentThread = thread + } + } + } + } +} diff --git a/pkg/models/like.go b/pkg/models/like.go new file mode 100644 index 0000000..b9a6323 --- /dev/null +++ b/pkg/models/like.go @@ -0,0 +1,145 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Like table. +type Like struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + TableName string + TableID uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// LikeableTables are the set of table names that allow likes (used by the JSON API). +var LikeableTables = map[string]interface{}{ + "photos": nil, + "users": nil, +} + +// AddLike to something. +func AddLike(user *User, tableName string, tableID uint64) error { + // Already has a like? + var like = &Like{} + exist := DB.Model(like).Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).First(&like) + if exist.Error == nil { + return nil + } + + // Create it. + like = &Like{ + UserID: user.ID, + TableName: tableName, + TableID: tableID, + } + return DB.Create(like).Error +} + +// Unlike something. +func Unlike(user *User, tableName string, tableID uint64) error { + result := DB.Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).Delete(&Like{}) + return result.Error +} + +// CountLikes on something. +func CountLikes(tableName string, tableID uint64) int64 { + var count int64 + DB.Model(&Like{}).Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Count(&count) + return count +} + +// LikedIDs filters a set of table IDs to ones the user likes. +func LikedIDs(user *User, tableName string, tableIDs []uint64) ([]uint64, error) { + var result = []uint64{} + if r := DB.Table( + "likes", + ).Select( + "table_id", + ).Where( + "user_id = ? AND table_name = ? AND table_id IN ?", + user.ID, tableName, tableIDs, + ).Scan(&result); r.Error != nil { + return result, r.Error + } + + return result, nil +} + +// LikeMap maps table IDs to Likes metadata. +type LikeMap map[uint64]*LikeStats + +// Get like stats from the map. +func (lm LikeMap) Get(id uint64) *LikeStats { + if stats, ok := lm[id]; ok { + return stats + } + return &LikeStats{} +} + +// LikeStats holds mapped statistics about liked objects. +type LikeStats struct { + Count int64 // how many total + UserLikes bool // current user likes it +} + +// MapLikes over a set of table IDs. +func MapLikes(user *User, tableName string, tableIDs []uint64) LikeMap { + var result = LikeMap{} + + // Initialize the result set. + for _, id := range tableIDs { + result[id] = &LikeStats{} + } + + // Hold the result of the grouped count query. + type group struct { + ID uint64 + Likes int64 + } + var groups = []group{} + + // Map the counts of likes to each of these IDs. + if res := DB.Table( + "likes", + ).Select( + "table_id AS id, count(id) AS likes", + ).Where( + "table_name = ? AND table_id IN ?", + tableName, tableIDs, + ).Group("table_id").Scan(&groups); res.Error != nil { + log.Error("MapLikes: count query: %s", res.Error) + } + + // Map the counts back in. + for _, row := range groups { + if stats, ok := result[row.ID]; ok { + stats.Count = row.Likes + } + } + + // Does the CURRENT USER like any of these IDs? + if likedIDs, err := LikedIDs(user, tableName, tableIDs); err == nil { + log.Error("USER LIKES IDS: %+v", likedIDs) + for _, id := range likedIDs { + if stats, ok := result[id]; ok { + stats.UserLikes = true + } + } + } + + return result +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 01a8ea3..651547c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -16,4 +16,9 @@ func AutoMigrate() { DB.AutoMigrate(&Friend{}) DB.AutoMigrate(&Block{}) DB.AutoMigrate(&Feedback{}) + DB.AutoMigrate(&Forum{}) + DB.AutoMigrate(&Thread{}) + DB.AutoMigrate(&Comment{}) + DB.AutoMigrate(&Like{}) + DB.AutoMigrate(&Notification{}) } diff --git a/pkg/models/notification.go b/pkg/models/notification.go new file mode 100644 index 0000000..cf34d72 --- /dev/null +++ b/pkg/models/notification.go @@ -0,0 +1,175 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" + "gorm.io/gorm" +) + +// Notification table. +type Notification struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + AboutUserID *uint64 `form:"index"` // the other party of this notification + User User `gorm:"foreignKey:about_user_id"` + Type NotificationType // like, comment, ... + Read bool `gorm:"index"` + TableName string // on which of your tables (photos, comments, ...) + TableID uint64 + Message string // text associated, e.g. copy of comment added + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (n *Notification) Preload() *gorm.DB { + return DB.Preload("User.ProfilePhoto") +} + +type NotificationType string + +const ( + NotificationLike NotificationType = "like" + NotificationComment = "comment" + NotificationCustom = "custom" // custom message pushed +) + +// CreateNotification +func CreateNotification(n *Notification) error { + return DB.Create(n).Error +} + +// GetNotification by ID. +func GetNotification(id uint64) (*Notification, error) { + var n *Notification + result := DB.Model(n).First(&n, id) + return n, result.Error +} + +// RemoveNotification about a table ID, e.g. when removing a like. +func RemoveNotification(tableName string, tableID uint64) error { + result := DB.Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Delete(&Notification{}) + return result.Error +} + +// MarkNotificationsRead sets all a user's notifications to read. +func MarkNotificationsRead(user *User) error { + return DB.Model(&Notification{}).Where( + "user_id = ? AND read IS NOT TRUE", + user.ID, + ).Update("read", true).Error +} + +// CountUnreadNotifications gets the count of unread Notifications for a user. +func CountUnreadNotifications(userID uint64) (int64, error) { + query := DB.Where( + "user_id = ? AND read = ?", + userID, + false, + ) + + var count int64 + result := query.Model(&Notification{}).Count(&count) + return count, result.Error +} + +// PaginateNotifications returns the user's notifications. +func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) { + var ns = []*Notification{} + + query := (&Notification{}).Preload().Where( + "user_id = ?", + user.ID, + ).Order( + pager.Sort, + ) + + query.Model(&Notification{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns) + return ns, result.Error +} + +// Save a notification. +func (n *Notification) Save() error { + return DB.Save(n).Error +} + +// NotificationBody can store remote tables mapped. +type NotificationBody struct { + PhotoID uint64 + Photo *Photo +} + +type NotificationMap map[uint64]*NotificationBody + +// Get a notification's body from the map. +func (m NotificationMap) Get(id uint64) *NotificationBody { + if body, ok := m[id]; ok { + return body + } + return &NotificationBody{} +} + +// MapNotifications loads associated assets, like Photos, mapped to their notification ID. +func MapNotifications(ns []*Notification) NotificationMap { + var ( + IDs = []uint64{} + result = NotificationMap{} + ) + + // Collect notification IDs. + for _, row := range ns { + IDs = append(IDs, row.ID) + result[row.ID] = &NotificationBody{} + } + + type scanner struct { + PhotoID uint64 + NotificationID uint64 + } + var scan []scanner + + // Load all of these that have photos. + err := DB.Table( + "notifications", + ).Joins( + "JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)", + ).Select( + "photos.id AS photo_id", + "notifications.id AS notification_id", + ).Where( + "notifications.id IN ?", + IDs, + ).Scan(&scan) + if err != nil { + log.Error("Couldn't select photo IDs for notifications: %s", err) + } + + // Collect and load all the photos by ID. + var photoIDs = []uint64{} + for _, row := range scan { + // Store the photo ID in the result now. + result[row.NotificationID].PhotoID = row.PhotoID + photoIDs = append(photoIDs, row.PhotoID) + } + + // Load the photos. + if len(photoIDs) > 0 { + if photos, err := GetPhotos(photoIDs); err != nil { + log.Error("Couldn't load photo IDs for notifications: %s", err) + } else { + // Marry them to their notification IDs. + for _, body := range result { + if photo, ok := photos[body.PhotoID]; ok { + body.Photo = photo + } + } + } + } + + return result +} diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 576d841..3d4a10e 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -76,6 +76,21 @@ func GetPhoto(id uint64) (*Photo, error) { return p, result.Error } +// GetPhotos by an array of IDs, mapped to their IDs. +func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) { + var ( + mp = map[uint64]*Photo{} + ps = []*Photo{} + ) + + result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps) + for _, row := range ps { + mp[row.ID] = row + } + + return mp, result.Error +} + /* PaginateUserPhotos gets a page of photos belonging to a user ID. */ diff --git a/pkg/models/thread.go b/pkg/models/thread.go new file mode 100644 index 0000000..f78c346 --- /dev/null +++ b/pkg/models/thread.go @@ -0,0 +1,258 @@ +package models + +import ( + "errors" + "fmt" + "strings" + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" + "gorm.io/gorm" +) + +// Thread table - a post within a Forum. +type Thread struct { + ID uint64 `gorm:"primaryKey"` + ForumID uint64 `gorm:"index"` + Forum Forum + Pinned bool `gorm:"index"` + Explicit bool `gorm:"index"` + NoReply bool + Title string + CommentID uint64 `gorm:"index"` + Comment Comment // first comment of the thread + Views uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (f *Thread) Preload() *gorm.DB { + return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto") +} + +// GetThread by ID. +func GetThread(id uint64) (*Thread, error) { + t := &Thread{} + result := t.Preload().First(&t, id) + return t, result.Error +} + +// GetThreads queries a set of thread IDs and returns them mapped. +func GetThreads(IDs []uint64) (map[uint64]*Thread, error) { + var ( + mt = map[uint64]*Thread{} + ts = []*Thread{} + ) + + result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts) + for _, row := range ts { + mt[row.ID] = row + } + + return mt, result.Error +} + +// CreateThread creates a new thread with proper Comment structure. +func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) { + thread := &Thread{ + ForumID: forumID, + Title: title, + Pinned: pinned, + Explicit: explicit, + NoReply: noReply && user.IsAdmin, + Comment: Comment{ + User: *user, + Message: message, + }, + } + + log.Error("CreateThread: Going to post %+v", thread) + + // Create the thread & comment first... + result := DB.Create(thread) + if result.Error != nil { + return nil, result.Error + } + + // Fill out the Comment with proper reverse foreign keys. + thread.Comment.TableName = "threads" + thread.Comment.TableID = thread.ID + log.Error("Saving updated comment: %+v", thread) + result = DB.Save(&thread.Comment) + return thread, result.Error +} + +// Reply to a thread, adding an additional comment. +func (t *Thread) Reply(user *User, message string) (*Comment, error) { + // Save the thread on reply, updating its timestamp. + if err := t.Save(); err != nil { + log.Error("Thread.Reply: couldn't ping UpdatedAt on thread: %s", err) + } + + return AddComment(user, "threads", t.ID, message) +} + +// DeleteReply removes a comment from a thread. If it is the primary comment, deletes the whole thread. +func (t *Thread) DeleteReply(comment *Comment) error { + // Sanity check that this reply is one of ours. + if !(comment.TableName == "threads" && comment.TableID == t.ID) { + return errors.New("that comment doesn't belong to this thread") + } + + // Is this the primary comment that started the thread? If so, delete the whole thread. + if comment.ID == t.CommentID { + log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title) + return t.Delete() + } + + // Remove just this comment. + return comment.Delete() +} + +// PinnedThreads returns all pinned threads in a forum (there should generally be few of these). +func PinnedThreads(forum *Forum) ([]*Thread, error) { + var ( + ts = []*Thread{} + query = (&Thread{}).Preload().Where( + "forum_id = ? AND pinned IS TRUE", + forum.ID, + ).Order("updated_at desc") + ) + + result := query.Find(&ts) + return ts, result.Error +} + +// PaginateThreads provides a forum index view of posts, minus pinned posts. +func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { + var ( + ts = []*Thread{} + query = (&Thread{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + // Always filters. + wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE") + placeholders = append(placeholders, forum.ID) + + // If the user hasn't opted in for Explicit, hide NSFW threads. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit IS NOT TRUE") + } + + query = query.Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order(pager.Sort) + + query.Model(&Thread{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts) + return ts, result.Error +} + +// View a thread, incrementing its View count but not its UpdatedAt. +func (t *Thread) View() error { + return DB.Model(&Thread{}).Where( + "id = ?", + t.ID, + ).Updates(map[string]interface{}{ + "views": t.Views + 1, + "updated_at": t.UpdatedAt, + }).Error +} + +// Save a thread, updating its timestamp. +func (t *Thread) Save() error { + return DB.Save(t).Error +} + +// Delete a thread and all of its comments. +func (t *Thread) Delete() error { + // Remove all comments. + result := DB.Where( + "table_name = ? AND table_id = ?", + "threads", t.ID, + ).Delete(&Comment{}) + if result.Error != nil { + return fmt.Errorf("deleting comments for thread: %s", result.Error) + } + + // Remove the thread itself. + return DB.Delete(t).Error +} + +// ThreadStatistics queries for reply/view count for threads. +type ThreadStatistics struct { + Replies uint64 + Views uint64 +} + +type ThreadStatsMap map[uint64]*ThreadStatistics + +// MapThreadStatistics looks up statistics for a set of threads. +func MapThreadStatistics(threads []*Thread) ThreadStatsMap { + var ( + result = ThreadStatsMap{} + IDs = []uint64{} + ) + + // Collect thread IDs and initialize the map. + for _, thread := range threads { + IDs = append(IDs, thread.ID) + result[thread.ID] = &ThreadStatistics{ + Views: thread.Views, + } + } + + // Hold the result of the count/group by query. + type group struct { + ID uint64 + Replies uint64 + } + var groups = []group{} + + // Count comments grouped by thread IDs. + err := DB.Table( + "comments", + ).Select( + "table_id AS id, count(id) AS replies", + ).Where( + "table_name = ? AND table_id IN ?", + "threads", IDs, + ).Group("table_id").Scan(&groups) + + if err != nil { + log.Error("MapThreadStatistics: SQL error: %s") + } + + // Map the results in. + for _, row := range groups { + log.Error("Got row: %+v", row) + if stats, ok := result[row.ID]; ok { + stats.Replies = row.Replies + + // Remove the OG comment from the count. + if stats.Replies > 0 { + stats.Replies-- + } + } + } + + return result +} + +// Has stats for this thread? (we should..) +func (ts ThreadStatsMap) Has(threadID uint64) bool { + _, ok := ts[threadID] + return ok +} + +// Get thread stats. +func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics { + if stats, ok := ts[threadID]; ok { + return stats + } + return nil +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 413ef00..2d87c5f 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -9,6 +9,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/controller/admin" "git.kirsle.net/apps/gosocial/pkg/controller/api" "git.kirsle.net/apps/gosocial/pkg/controller/block" + "git.kirsle.net/apps/gosocial/pkg/controller/forum" "git.kirsle.net/apps/gosocial/pkg/controller/friend" "git.kirsle.net/apps/gosocial/pkg/controller/inbox" "git.kirsle.net/apps/gosocial/pkg/controller/index" @@ -55,16 +56,24 @@ func New() http.Handler { // Certification Required. Pages that only full (verified) members can access. mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) mux.Handle("/members", middleware.CertRequired(account.Search())) + mux.Handle("/forum", middleware.CertRequired(forum.Landing())) + mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost())) + mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread())) + mux.Handle("/f/", middleware.CertRequired(forum.Forum())) // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) + mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage())) + mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/users/me", api.LoginOK()) + mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes())) + mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) // Static files. mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath)))) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index f280d73..3065d6e 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -58,6 +58,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap { } 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++ { diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 16f57bb..9bf0bb3 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -13,6 +13,8 @@ import ( // MergeVars mixes in globally available template variables. The http.Request is optional. func MergeVars(r *http.Request, m map[string]interface{}) { m["Title"] = config.Title + m["BuildHash"] = config.RuntimeBuild + m["BuildDate"] = config.RuntimeBuildDate m["Subtitle"] = config.Subtitle m["YYYY"] = time.Now().Year() @@ -31,9 +33,10 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["SessionImpersonated"] = false // User notification counts for nav bar. - m["NavUnreadMessages"] = 0 // New messages - m["NavFriendRequests"] = 0 // Friend requests - m["NavTotalNotifications"] = 0 // Total of above + m["NavUnreadMessages"] = 0 // New messages + m["NavFriendRequests"] = 0 // Friend requests + m["NavUnreadNotifications"] = 0 // general notifications + m["NavTotalNotifications"] = 0 // Total of above // Admin notification counts for nav bar. m["NavCertificationPhotos"] = 0 // Cert. photos needing approval @@ -50,11 +53,21 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["LoggedIn"] = true m["CurrentUser"] = user + // Get user recent notifications. + /*notifPager := &models.Pagination{ + Page: 1, + PerPage: 10, + } + if notifs, err := models.PaginateNotifications(user, notifPager); err == nil { + m["Notifications"] = notifs + }*/ + // Collect notification counts. var ( // For users - countMessages int64 - countFriendReqs int64 + countMessages int64 + countFriendReqs int64 + countNotifications int64 // For admins countCertPhotos int64 @@ -77,6 +90,14 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) } + // Count other notifications. + if count, err := models.CountUnreadNotifications(user.ID); err == nil { + m["NavUnreadNotifications"] = count + countNotifications = count + } else { + log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) + } + // Are we admin? if user.IsAdmin { // Any pending certification photos or feedback? @@ -90,6 +111,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { } // Total count for user notifications. - m["NavTotalNotifications"] = countMessages + countFriendReqs + countCertPhotos + countFeedback + m["NavTotalNotifications"] = countMessages + countFriendReqs + countNotifications + countCertPhotos + countFeedback } } diff --git a/web/static/js/likes.js b/web/static/js/likes.js new file mode 100644 index 0000000..4f5ae26 --- /dev/null +++ b/web/static/js/likes.js @@ -0,0 +1,56 @@ +// Like button handler. +document.addEventListener('DOMContentLoaded', () => { + const red = "has-text-danger"; + let busy = false; + + // Bind to the like buttons. + (document.querySelectorAll(".nonshy-like-button") || []).forEach(node => { + node.addEventListener("click", (e) => { + if (busy) return; + + let $icon = node.querySelector(".icon"), + $label = node.querySelector(".nonshy-likes"), + tableName = node.dataset.tableName, + tableID = node.dataset.tableId, + liking = false; + + // Toggle the color of the heart. + if ($icon.classList.contains(red)) { + $icon.classList.remove(red); + } else { + liking = true; + $icon.classList.add(red); + } + + // Ajax request to backend. + busy = true; + return fetch("/v1/likes", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "name": tableName, // TODO + "id": parseInt(tableID), + "unlike": !liking, + }), + }) + .then((response) => response.json()) + .then((data) => { + let likes = data.data.likes; + if (likes === 0) { + $label.innerHTML = "Like"; + } else { + $label.innerHTML = `Like (${likes})`; + } + }).catch(resp => { + window.alert(resp); + }).finally(() => { + busy = false; + }) + }); + }); +}); \ No newline at end of file diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 4a5b4fc..e55d47f 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -136,18 +136,157 @@ + {{$Root := .}} +
-
+

Notifications

- TBD. +
+
+ {{if gt .NavUnreadNotifications 0}} + {{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}. + {{else}} + No unread notifications. + {{end}} +
+ +
+ + + + {{range .Notifications}} + {{$Body := $Root.NotifMap.Get .ID}} + + + + {{end}} + +
+
+
+ {{if not .Read}} +
+ NEW! +
+ {{end}} + +
+ {{if .User.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+
+
+ {{if eq .Type "like"}} + + + {{.User.Username}} + liked your + {{if eq .TableName "photos"}} + photo. + {{else if eq .TableName "users"}} + profile page. + {{else}} + {{.TableName}}. + {{end}} + + {{else}} + {{.User.Username}} {{.Type}} {{.TableName}} {{.TableID}} + {{end}} +
+ + + {{if $Body.Photo}} +
+ {{or $Body.Photo.Caption "No caption."}} +
+ {{end}} + +
+ + {{SincePrettyCoarse .CreatedAt}} ago + +
+ + + {{if $Body.PhotoID}} +
+ + + {{if $Body.Photo.Caption}} + {{$Body.Photo.Caption}} + {{else}} + No caption. + {{end}} +
+ {{end}} +
+ +
+ + {{if .Pager.HasNext}} + + {{end}}
+ + {{end}} \ No newline at end of file diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 1e046fa..f162679 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -121,16 +121,21 @@ - + {{$Like := .LikeMap.Get .User.ID}} +
+ -
--> +
diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html index 0961aec..d22b2db 100644 --- a/web/templates/admin/user_actions.html +++ b/web/templates/admin/user_actions.html @@ -151,16 +151,4 @@
- - {{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index f9bb1f8..3d80f33 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -52,10 +52,10 @@ Gallery - + Forum + @@ -108,7 +108,7 @@