feat: add study session comments #1

merged
opened by brookjeynes.dev targeting master from bj/2025-09-05/feat/study-session-comments
+7 -1
internal/server/views/views.go
···
// The current logged in user.
User *types.User
Notifications []db.NotificationWithBskyHandle
-
ActiveTab string
+
}
+
+
type StudySessionPageParams struct {
+
// The current logged in user.
+
User *types.User
+
StudySession db.StudySessionFeedItem
+
DoesOwn bool
}
+2 -2
internal/server/views/friends.templ
···
<div class="container mx-auto max-w-2xl px-4 py-8">
<div class="flex items-center justify-between mb-8">
<div>
-
<h1 class="text-3xl font-bold text-gray-900">Friends</h1>
-
<p class="text-gray-600 mt-1">Connect with fellow language learners</p>
+
<h1 class="text-3xl font-bold">Friends</h1>
+
<p class="mt-1">Connect with fellow language learners</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
+28 -8
internal/db/follow.go
···
package db
import (
+
"database/sql"
"fmt"
+
"log"
"strings"
"time"
)
···
return IsSelf
}
-
var follows, isFollowed bool
query := `
-
select
-
exists(select 1 from follows where user_did = ? and subject_did = ?),
-
exists(select 1 from follows where user_did = ? and subject_did = ?)
-
`
-
err := e.QueryRow(query, userDid, subjectDid, subjectDid, userDid).Scan(&follows, &isFollowed)
+
SELECT
+
-- Count of rows where the user follows the subject
+
COUNT(CASE WHEN user_did = ? AND subject_did = ? THEN 1 END),
+
-- Count of rows where the subject follows the user
+
COUNT(CASE WHEN user_did = ? AND subject_did = ? THEN 1 END)
+
FROM
+
follows
+
WHERE
+
(user_did = ? AND subject_did = ?) OR (user_did = ? AND subject_did = ?);
+
`
+
+
var userFollowsSubject, subjectFollowsUser int
+
err := e.QueryRow(
+
query,
+
userDid, subjectDid,
+
subjectDid, userDid,
+
userDid, subjectDid,
+
subjectDid, userDid,
+
).Scan(&userFollowsSubject, &subjectFollowsUser)
+
if err != nil {
+
if err == sql.ErrNoRows {
+
return IsNotFollowing
+
}
+
log.Printf("failed to query follow status: %v", err)
return IsNotFollowing
}
-
if follows && isFollowed {
+
if userFollowsSubject > 0 && subjectFollowsUser > 0 {
return IsMutual
-
} else if follows {
+
} else if userFollowsSubject > 0 {
return IsFollowing
} else {
return IsNotFollowing
+376
api/yoten/cbor_gen.go
···
return nil
}
+
func (t *FeedComment) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 5
+
+
if t.Reply == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.Body (string) (string)
+
if len("body") > 1000000 {
+
return xerrors.Errorf("Value in field \"body\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("body")); err != nil {
+
return err
+
}
+
+
if len(t.Body) > 1000000 {
+
return xerrors.Errorf("Value in field t.Body was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Body)); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.yoten.feed.comment"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("app.yoten.feed.comment")); err != nil {
+
return err
+
}
+
+
// t.Reply (yoten.FeedComment_Reply) (struct)
+
if t.Reply != nil {
+
+
if len("reply") > 1000000 {
+
return xerrors.Errorf("Value in field \"reply\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reply"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("reply")); err != nil {
+
return err
+
}
+
+
if err := t.Reply.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
}
+
+
// t.Subject (string) (string)
+
if len("subject") > 1000000 {
+
return xerrors.Errorf("Value in field \"subject\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("subject")); err != nil {
+
return err
+
}
+
+
if len(t.Subject) > 1000000 {
+
return xerrors.Errorf("Value in field t.Subject was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
+
return err
+
}
+
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
return err
+
}
+
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *FeedComment) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = FeedComment{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("FeedComment: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 9)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.Body (string) (string)
+
case "body":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Body = string(sval)
+
}
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Reply (yoten.FeedComment_Reply) (struct)
+
case "reply":
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.Reply = new(FeedComment_Reply)
+
if err := t.Reply.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.Reply pointer: %w", err)
+
}
+
}
+
+
}
+
// t.Subject (string) (string)
+
case "subject":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Subject = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *FeedComment_Reply) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{162}); err != nil {
+
return err
+
}
+
+
// t.Root (string) (string)
+
if len("root") > 1000000 {
+
return xerrors.Errorf("Value in field \"root\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("root"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("root")); err != nil {
+
return err
+
}
+
+
if len(t.Root) > 1000000 {
+
return xerrors.Errorf("Value in field t.Root was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Root))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Root)); err != nil {
+
return err
+
}
+
+
// t.Parent (string) (string)
+
if len("parent") > 1000000 {
+
return xerrors.Errorf("Value in field \"parent\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("parent")); err != nil {
+
return err
+
}
+
+
if len(t.Parent) > 1000000 {
+
return xerrors.Errorf("Value in field t.Parent was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Parent))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Parent)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *FeedComment_Reply) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = FeedComment_Reply{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("FeedComment_Reply: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 6)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.Root (string) (string)
+
case "root":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Root = string(sval)
+
}
+
// t.Parent (string) (string)
+
case "parent":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Parent = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
+35
api/yoten/feedcomment.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package yoten
+
+
// schema: app.yoten.feed.comment
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
FeedCommentNSID = "app.yoten.feed.comment"
+
)
+
+
func init() {
+
util.RegisterType("app.yoten.feed.comment", &FeedComment{})
+
} //
+
// RECORDTYPE: FeedComment
+
type FeedComment struct {
+
LexiconTypeID string `json:"$type,const=app.yoten.feed.comment" cborgen:"$type,const=app.yoten.feed.comment"`
+
Body string `json:"body" cborgen:"body"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// reply: Indicates that this comment is a reply to another comment.
+
Reply *FeedComment_Reply `json:"reply,omitempty" cborgen:"reply,omitempty"`
+
// subject: A reference to the study session being commented on.
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+
+
// Indicates that this comment is a reply to another comment.
+
type FeedComment_Reply struct {
+
// parent: A reference to the specific comment being replied to.
+
Parent string `json:"parent" cborgen:"parent"`
+
// root: A reference to the original study session (the root of the conversation).
+
Root string `json:"root" cborgen:"root"`
+
}
+2
cmd/gen.go
···
yoten.ActivityDef{},
yoten.GraphFollow{},
yoten.FeedReaction{},
+
yoten.FeedComment{},
+
yoten.FeedComment_Reply{},
}
for name, rt := range AllLexTypes() {
+1
internal/server/views/new-study-session.templ
···
stopAndLog() {
this.pause();
const form = this.$root;
+
this.timerState = 'stopped';
let durationSeconds = form.querySelector('input[name="duration_seconds"]');
if (!durationSeconds) {
+50
lexicons/feed/comment.json
···
+
{
+
"lexicon": 1,
+
"id": "app.yoten.feed.comment",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A declaration of a Yōten comment.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["subject", "body", "createdAt"],
+
"properties": {
+
"body": {
+
"type": "string",
+
"minLength": 1,
+
"maxLength": 256
+
},
+
"subject": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "A reference to the study session being commented on."
+
},
+
"reply": {
+
"type": "object",
+
"description": "Indicates that this comment is a reply to another comment.",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "A reference to the original study session (the root of the conversation)."
+
},
+
"parent": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "A reference to the specific comment being replied to."
+
}
+
}
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+4
internal/clients/posthog/posthog.go
···
ReactionRecordCreatedEvent string = "reaction-record-created"
ReactionRecordDeletedEvent string = "reaction-record-deleted"
+
CommentRecordCreatedEvent string = "comment-record-created"
+
CommentRecordDeletedEvent string = "comment-record-deleted"
+
CommentRecordEditedEvent string = "comment-record-edited"
+
ActivityDefRecordFirstCreated string = "activity-def-record-first-created"
ActivityDefRecordCreatedEvent string = "activity-def-record-created"
ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+3 -1
internal/server/app.go
···
jc, err := consumer.NewJetstreamClient(
config.Jetstream.Endpoint,
"yoten",
-
[]string{yoten.ActorProfileNSID,
+
[]string{
+
yoten.ActorProfileNSID,
yoten.FeedSessionNSID,
yoten.FeedResourceNSID,
+
yoten.FeedCommentNSID,
yoten.FeedReactionNSID,
yoten.ActivityDefNSID,
yoten.GraphFollowNSID,
+1 -1
internal/server/views/partials/activity.templ
···
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
type="button"
id="delete-button"
-
hx-disabled-elt="delete-button,#edit-button"
+
hx-disabled-elt="#delete-button,#edit-button"
hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) }
>
<i class="w-4 h-4" data-lucide="trash-2"></i>
+1 -1
internal/server/views/partials/resource.templ
···
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
type="button"
id="delete-button"
-
hx-disabled-elt="delete-button,#edit-button"
+
hx-disabled-elt="#delete-button,#edit-button"
hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) }
>
<i class="w-4 h-4" data-lucide="trash-2"></i>
+3 -3
internal/server/oauth/handler/handler.go
···
}
}()
-
error := r.FormValue("error")
+
callbackErr := r.FormValue("error")
errorDescription := r.FormValue("error_description")
-
if error != "" || errorDescription != "" {
-
log.Printf("oauth callback error: %s, %s", error, errorDescription)
+
if callbackErr != "" || errorDescription != "" {
+
log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription)
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
return
}
+2
internal/server/handlers/router.go
···
r.Route("/comment", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(h.Oauth))
r.Post("/new", h.HandleNewComment)
+
r.Get("/edit/{rkey}", h.HandleEditCommentPage)
+
r.Post("/edit/{rkey}", h.HandleEditCommentPage)
r.Delete("/{rkey}", h.HandleDeleteComment)
})
+128
internal/db/comment.go
···
import (
"database/sql"
"fmt"
+
"sort"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
BskyProfile types.BskyProfile
}
+
type CommentWithLocalProfile struct {
+
Comment
+
ProfileLevel int
+
ProfileDisplayName string
+
}
+
type Comment struct {
ID int
Did string
···
return comment, nil
}
+
+
func GetCommentsForSession(e Execer, studySessionUri string, limit, offset int) ([]CommentWithLocalProfile, error) {
+
topLevelCommentsQuery := `
+
select
+
c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri,
+
c.body, c.is_deleted, c.created_at,
+
p.display_name, p.level
+
from comments c
+
join profiles p on c.did = p.did
+
where c.study_session_uri = ? and c.parent_comment_uri is null
+
order by c.created_at asc
+
limit ? offset ?;
+
`
+
rows, err := e.Query(topLevelCommentsQuery, studySessionUri, limit, offset)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query top-level comments: %w", err)
+
}
+
defer rows.Close()
+
+
allCommentsMap := make(map[string]CommentWithLocalProfile)
+
var topLevelCommentUris []string
+
+
for rows.Next() {
+
comment, err := scanCommentWithLocalProfile(rows)
+
if err != nil {
+
return nil, err
+
}
+
allCommentsMap[comment.CommentAt().String()] = comment
+
topLevelCommentUris = append(topLevelCommentUris, comment.CommentAt().String())
+
}
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating top-level comment rows: %w", err)
+
}
+
rows.Close()
+
+
if len(topLevelCommentUris) == 0 {
+
return []CommentWithLocalProfile{}, nil
+
}
+
+
repliesQuery := `
+
select
+
c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri,
+
c.body, c.is_deleted, c.created_at,
+
p.display_name, p.level
+
from comments c
+
join profiles p on c.did = p.did
+
where c.study_session_uri = ? and c.parent_comment_uri in (` + GetPlaceholders(len(topLevelCommentUris)) + `);
+
`
+
args := make([]any, len(topLevelCommentUris)+1)
+
args[0] = studySessionUri
+
for i, uri := range topLevelCommentUris {
+
args[i+1] = uri
+
}
+
+
replyRows, err := e.Query(repliesQuery, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query replies: %w", err)
+
}
+
defer replyRows.Close()
+
+
for replyRows.Next() {
+
reply, err := scanCommentWithLocalProfile(replyRows)
+
if err != nil {
+
return nil, err
+
}
+
allCommentsMap[reply.CommentAt().String()] = reply
+
}
+
if err = replyRows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating reply rows: %w", err)
+
}
+
+
finalComments := make([]CommentWithLocalProfile, 0, len(allCommentsMap))
+
for _, comment := range allCommentsMap {
+
finalComments = append(finalComments, comment)
+
}
+
+
sort.Slice(finalComments, func(i, j int) bool {
+
return finalComments[i].CreatedAt.Before(finalComments[j].CreatedAt)
+
})
+
+
return finalComments, nil
+
}
+
+
func scanCommentWithLocalProfile(rows *sql.Rows) (CommentWithLocalProfile, error) {
+
var comment CommentWithLocalProfile
+
var parentUri sql.NullString
+
var studySessionUriStr string
+
var createdAtStr string
+
+
err := rows.Scan(
+
&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr,
+
&parentUri, &comment.Body, &comment.IsDeleted, &createdAtStr,
+
&comment.ProfileDisplayName, &comment.ProfileLevel,
+
)
+
if err != nil {
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to scan comment row: %w", err)
+
}
+
+
comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err)
+
}
+
+
parsedStudySessionUri, err := syntax.ParseATURI(studySessionUriStr)
+
if err != nil {
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err)
+
}
+
comment.StudySessionUri = parsedStudySessionUri
+
+
if parentUri.Valid {
+
parsedParentUri, err := syntax.ParseATURI(parentUri.String)
+
if err != nil {
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err)
+
}
+
comment.ParentCommentUri = &parsedParentUri
+
}
+
+
comment.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
+
+
return comment, nil
+
}
+10
internal/db/utils.go
···
package db
import (
+
"strings"
+
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
···
titleStr := caser.String(str)
return titleStr
}
+
+
// Generates `?, ?, ?` for SQL IN clauses.
+
func GetPlaceholders(count int) string {
+
if count < 1 {
+
return ""
+
}
+
return strings.Repeat("?,", count-1) + "?"
+
}
+31
internal/server/views/partials/comment-feed.templ
···
+
package partials
+
+
import "fmt"
+
+
templ CommentFeed(params CommentFeedProps) {
+
for _, comment := range params.Feed {
+
{{
+
isSelf := false
+
if params.User != nil {
+
isSelf = params.User.Did == comment.Did
+
}
+
}}
+
@Comment(CommentProps{
+
Comment: comment,
+
DoesOwn: isSelf,
+
})
+
}
+
if params.NextPage > 0 {
+
<div
+
id="next-feed-segment"
+
hx-get={ templ.SafeURL(fmt.Sprintf("/%s/session/%s/feed?page=%d", params.StudySessionDid,
+
params.StudySessionRkey, params.NextPage)) }
+
hx-trigger="revealed"
+
hx-swap="outerHTML"
+
>
+
<div class="flex justify-center py-4">
+
<i data-lucide="loader-circle" class="w-6 h-6 animate-spin text-text-muted"></i>
+
</div>
+
</div>
+
}
+
}
+36 -35
internal/server/views/partials/comment.templ
···
<p class="text-text-muted text-sm">&commat;{ params.Comment.BskyProfile.Handle }</p>
</div>
</div>
-
// TODO: Only show on comments you own
-
<details class="relative inline-block text-left">
-
<summary class="cursor-pointer list-none">
-
<div class="btn btn-muted p-2">
-
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
+
if params.DoesOwn {
+
<details class="relative inline-block text-left">
+
<summary class="cursor-pointer list-none">
+
<div class="btn btn-muted p-2">
+
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
+
</div>
+
</summary>
+
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
+
<button
+
class="btn hover:bg-bg group justify-start px-2"
+
type="button"
+
id="edit-button"
+
hx-disabled-elt="#delete-button,#edit-button"
+
hx-target={ "#" + elementId }
+
hx-swap="outerHTML"
+
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
+
>
+
<i class="w-4 h-4" data-lucide="square-pen"></i>
+
<span class="text-sm">Edit</span>
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
+
</button>
+
<button
+
class="btn text-red-600 hover:bg-bg group justify-start px-2"
+
type="button"
+
id="delete-button"
+
hx-disabled-elt="#delete-button,#edit-button"
+
hx-target={ "#" + elementId }
+
hx-swap="outerHTML"
+
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) }
+
>
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
+
<span class="text-sm">Delete</span>
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
+
</button>
</div>
-
</summary>
-
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
-
<button
-
class="text-base cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
-
type="button"
-
id="edit-button"
-
hx-disabled-elt="#delete-button,#edit-button"
-
hx-target={ "#" + elementId }
-
hx-swap="outerSelf"
-
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
-
>
-
<i class="w-4 h-4" data-lucide="square-pen"></i>
-
Edit
-
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
-
</button>
-
<button
-
class="text-base text-red-600 cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
-
type="button"
-
id="delete-button"
-
hx-disabled-elt="#delete-button,#edit-button"
-
hx-target={ "#" + elementId }
-
hx-swap="outerSelf"
-
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) }
-
>
-
<i class="w-4 h-4" data-lucide="trash-2"></i>
-
Delete
-
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
-
</button>
-
</div>
-
</details>
+
</details>
+
}
</div>
<p class="leading-relaxed break-words">
{ params.Comment.Body }
+1 -1
internal/server/views/partials/reactions.templ
···
}
</div>
}
-
<div class="inline-block text-left w-fit">
+
<div class="inline-block text-left w-fit" title="reactions">
<button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2">
<i class="w-5 h-5" data-lucide="smile-plus"></i>
</button>
+1 -1
internal/server/views/partials/study-session.templ
···
SessionRkey: params.StudySession.Rkey,
ReactionEvents: params.StudySession.Reactions,
})
-
<a href={ studySessionUrl }>
+
<a href={ studySessionUrl } title="comments">
<i class="w-5 h-5" data-lucide="message-square-share"></i>
</a>
</div>
+3 -1
internal/server/views/study-session.templ
···
StudySession: params.StudySession,
})
@partials.Discussion(partials.DiscussionProps{
-
StudySessionUri: params.StudySession.StudySessionAt().String(),
+
StudySessionDid: params.StudySession.Did,
+
StudySessionRkey: params.StudySession.Rkey,
+
StudySessionUri: params.StudySession.StudySessionAt().String(),
})
</div>
}
+11 -1
internal/consumer/ingester.go
···
if err != nil {
return fmt.Errorf("failed to parse study session at-uri: %w", err)
}
+
subjectDid, err := subjectUri.Authority().AsDID()
+
if err != nil {
+
return fmt.Errorf("failed to identify subject did: %w", err)
+
}
body := record.Body
-
if len(body) == 0 {
+
if len(strings.TrimSpace(body)) == 0 {
return fmt.Errorf("invalid body: length cannot be 0")
}
···
tx.Rollback()
return fmt.Errorf("failed to upsert comment record: %w", err)
}
+
+
err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment)
+
if err != nil {
+
log.Println("failed to create notification record:", err)
+
}
+
return tx.Commit()
case models.CommitOperationDelete:
log.Println("deleting comment from pds request")
+7
internal/db/notification.go
···
const (
NotificationTypeFollow NotificationType = "follow"
NotificationTypeReaction NotificationType = "reaction"
+
NotificationTypeComment NotificationType = "comment"
)
type NotificationState string
···
RecipientDid string
ActorDid string
SubjectRkey string
+
SubjectDid string
State NotificationState
Type NotificationType
CreatedAt time.Time
···
return nil, fmt.Errorf("failed to parse at-uri: %w", err)
}
notification.SubjectRkey = subjectUri.RecordKey().String()
+
subjectDid, err := subjectUri.Authority().AsDID()
+
if err != nil {
+
return nil, fmt.Errorf("failed to identify subject did: %w", err)
+
}
+
notification.SubjectDid = subjectDid.String()
notifications = append(notifications, notification)
}
+1 -1
internal/server/handlers/comment.go
···
if !h.Config.Core.Dev {
event := posthog.Capture{
DistinctId: user.Did,
-
Event: ph.CommentRecordDeletedEvent,
+
Event: ph.CommentRecordCreatedEvent,
Properties: posthog.NewProperties().
Set("is_reply", newComment.ParentCommentUri != nil).
Set("character_count", len(newComment.Body)).