From bbfbd8177690b46c95c82d2af1b5ad8e2b2b9c31 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Fri, 5 Sep 2025 18:42:01 +1000 Subject: [PATCH] feat: add study session page --- internal/server/handlers/router.go | 1 + internal/server/handlers/study-session.go | 55 +++++++++++++++++++ .../server/views/partials/discussion.templ | 29 ++++++++++ internal/server/views/partials/partials.go | 3 + .../server/views/partials/study-session.templ | 18 ++++-- internal/server/views/study-session.templ | 20 +++++++ internal/server/views/views.go | 8 ++- 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 internal/server/views/partials/discussion.templ create mode 100644 internal/server/views/study-session.templ diff --git a/internal/server/handlers/router.go b/internal/server/handlers/router.go index 8177190..d7783a9 100644 --- a/internal/server/handlers/router.go +++ b/internal/server/handlers/router.go @@ -129,6 +129,7 @@ func (h *Handler) UserRouter(mw *middleware.Middleware) http.Handler { r.Route("/{user}", func(r chi.Router) { r.Get("/", h.HandleProfilePage) r.Get("/feed", h.HandleProfileFeed) + r.Get("/session/{rkey}", h.HandleStudySessionPage) }) }) diff --git a/internal/server/handlers/study-session.go b/internal/server/handlers/study-session.go index fb8f4fc..6f2430c 100644 --- a/internal/server/handlers/study-session.go +++ b/internal/server/handlers/study-session.go @@ -9,6 +9,7 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/identity" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" @@ -630,3 +631,57 @@ func (h *Handler) GetBskyProfileHydratedSessionFeed(feed []*db.StudySessionFeedI return nil } + +func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) { + user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r) + didOrHandle := chi.URLParam(r, "user") + if didOrHandle == "" { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + ident, ok := r.Context().Value("resolvedId").(identity.Identity) + if !ok { + w.WriteHeader(http.StatusNotFound) + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) + return + } + rkey := chi.URLParam(r, "rkey") + + studySession, err := db.GetStudySessionByRkey(h.Db, ident.DID.String(), rkey) + if err != nil { + log.Println("failed to retrieve study session:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve study session, try again later.") + return + } + + bskyProfile, err := bsky.GetBskyProfile(ident.DID.String()) + if err != nil { + log.Println("failed to retrieve bsky profile for study session:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve bsky profile, try again later.") + return + } + + profile, err := db.GetProfile(h.Db, ident.DID.String()) + if err != nil { + log.Println("failed to retrieve profile for study session:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile, try again later.") + return + } + + isSelf := false + if user != nil { + isSelf = user.Did == studySession.Did + } + + views.StudySessionPage(views.StudySessionPageParams{ + User: user, + DoesOwn: isSelf, + StudySession: db.StudySessionFeedItem{ + StudySession: *studySession, + ProfileDisplayName: profile.DisplayName, + ProfileLevel: profile.Level, + BskyProfile: bskyProfile, + }, + }).Render(r.Context(), w) +} diff --git a/internal/server/views/partials/discussion.templ b/internal/server/views/partials/discussion.templ new file mode 100644 index 0000000..aee0c7c --- /dev/null +++ b/internal/server/views/partials/discussion.templ @@ -0,0 +1,29 @@ +package partials + +templ Discussion(params DiscussionProps) { +
+
+ +

Discussion

+
+
+ +
+
+ / 256 +
+ +
+
+
+} diff --git a/internal/server/views/partials/partials.go b/internal/server/views/partials/partials.go index 92c6c6b..c0f9d0f 100644 --- a/internal/server/views/partials/partials.go +++ b/internal/server/views/partials/partials.go @@ -218,3 +218,6 @@ type NotificationFeedProps struct { Feed []db.NotificationWithBskyHandle NextPage int } + +type DiscussionProps struct { +} diff --git a/internal/server/views/partials/study-session.templ b/internal/server/views/partials/study-session.templ index f30304f..6f9cc64 100644 --- a/internal/server/views/partials/study-session.templ +++ b/internal/server/views/partials/study-session.templ @@ -69,6 +69,7 @@ templ studySessionAction(params StudySessionProps) { templ StudySession(params StudySessionProps) { {{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }} + {{ studySessionUrl := templ.SafeURL("/" + params.StudySession.Did + "/session/" + params.StudySession.Rkey) }}
@@ -148,12 +149,17 @@ templ StudySession(params StudySessionProps) { }
- @NewReactions(NewReactionsProps{ - User: params.User, - SessionDid: params.StudySession.Did, - SessionRkey: params.StudySession.Rkey, - ReactionEvents: params.StudySession.Reactions, - }) +
+ @NewReactions(NewReactionsProps{ + User: params.User, + SessionDid: params.StudySession.Did, + SessionRkey: params.StudySession.Rkey, + ReactionEvents: params.StudySession.Reactions, + }) + + + +
diff --git a/internal/server/views/study-session.templ b/internal/server/views/study-session.templ new file mode 100644 index 0000000..8c92a67 --- /dev/null +++ b/internal/server/views/study-session.templ @@ -0,0 +1,20 @@ +package views + +import ( + "yoten.app/internal/server/views/layouts" + "yoten.app/internal/server/views/partials" +) + +templ StudySessionPage(params StudySessionPageParams) { + @layouts.Base(layouts.BaseParams{Title: "study session"}) { + @partials.Header(partials.HeaderProps{User: params.User}) +
+ @partials.StudySession(partials.StudySessionProps{ + User: params.User, + DoesOwn: params.DoesOwn, + StudySession: params.StudySession, + }) + @partials.Discussion(partials.DiscussionProps{}) +
+ } +} diff --git a/internal/server/views/views.go b/internal/server/views/views.go index e8e379f..7f1fd74 100644 --- a/internal/server/views/views.go +++ b/internal/server/views/views.go @@ -123,5 +123,11 @@ type NotificationsPageParams struct { // 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.43.0 From 5512d09bcfbc6d8be227cb455d0a423d142747de Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Fri, 12 Sep 2025 17:15:50 +1000 Subject: [PATCH] feat: add comment lexicon and db struct --- api/yoten/cbor_gen.go | 376 ++++++++++++++++ api/yoten/feedcomment.go | 35 ++ cmd/gen.go | 2 + internal/db/comment.go | 62 +++ internal/db/db.go | 410 +++++++++--------- internal/server/views/new-study-session.templ | 1 + lexicons/feed/comment.json | 50 +++ 7 files changed, 739 insertions(+), 197 deletions(-) create mode 100644 api/yoten/feedcomment.go create mode 100644 internal/db/comment.go create mode 100644 lexicons/feed/comment.json diff --git a/api/yoten/cbor_gen.go b/api/yoten/cbor_gen.go index 2185913..43cf19f 100644 --- a/api/yoten/cbor_gen.go +++ b/api/yoten/cbor_gen.go @@ -648,6 +648,382 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { 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) diff --git a/api/yoten/feedcomment.go b/api/yoten/feedcomment.go new file mode 100644 index 0000000..7e70031 --- /dev/null +++ b/api/yoten/feedcomment.go @@ -0,0 +1,35 @@ +// 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"` +} diff --git a/cmd/gen.go b/cmd/gen.go index 2c96fa4..cb43395 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -28,6 +28,8 @@ func main() { yoten.ActivityDef{}, yoten.GraphFollow{}, yoten.FeedReaction{}, + yoten.FeedComment{}, + yoten.FeedComment_Reply{}, } for name, rt := range AllLexTypes() { diff --git a/internal/db/comment.go b/internal/db/comment.go new file mode 100644 index 0000000..efa3487 --- /dev/null +++ b/internal/db/comment.go @@ -0,0 +1,62 @@ +package db + +import ( + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "yoten.app/api/yoten" + "yoten.app/internal/types" +) + +type CommentWithBskyProfile struct { + Comment + BskyProfile types.BskyProfile +} + +type Comment struct { + ID int + Did string + Rkey string + StudySessionUri syntax.ATURI + ParentCommentUri *syntax.ATURI + Body string + IsDeleted bool + CreatedAt time.Time +} + +func (c Comment) CommentAt() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, yoten.FeedCommentNSID, c.Rkey)) +} + +func UpsertComment(e Execer, comment Comment) error { + _, err := e.Exec(` + insert into study_sessions ( + id, + did, + rkey, + study_session_uri, + parent_comment_uri, + body, + is_deleted, + created_at + ) + values () + on conflict(did, rkey) do update set + is_deleted = excluded.is_deleted, + body = excluded.body`, + comment.ID, + comment.Did, + comment.Rkey, + comment.StudySessionUri.String(), + comment.ParentCommentUri.String(), + comment.Body, + comment.IsDeleted, + comment.CreatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("failed to insert or update comment: %w", err) + } + + return nil +} diff --git a/internal/db/db.go b/internal/db/db.go index e95ef3d..1b58f12 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -29,206 +29,222 @@ func Make(dbPath string) (*DB, error) { return nil, fmt.Errorf("failed to open db: %w", err) } _, err = db.Exec(` - pragma journal_mode = WAL; - pragma synchronous = normal; - pragma foreign_keys = on; - pragma temp_store = memory; - pragma mmap_size = 30000000000; - pragma page_size = 32768; - pragma auto_vacuum = incremental; - pragma busy_timeout = 5000; - - create table if not exists oauth_requests ( - id integer primary key autoincrement, - auth_server_iss text not null, - state text not null, - did text not null, - handle text not null, - pds_url text not null, - pkce_verifier text not null, - dpop_auth_server_nonce text not null, - dpop_private_jwk text not null - ); - - create table if not exists oauth_sessions ( - id integer primary key autoincrement, - did text not null, - handle text not null, - pds_url text not null, - auth_server_iss text not null, - access_jwt text not null, - refresh_jwt text not null, - dpop_pds_nonce text, - dpop_auth_server_nonce text not null, - dpop_private_jwk text not null, - expiry text not null - ); - - create table if not exists profiles ( - -- id - id integer primary key autoincrement, - did text not null, - - -- data - display_name text not null, - description text, - location text, - xp integer not null default 0, -- total accumulated xp - level integer not null default 0, - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - -- constraints - unique(did) - ); - - create table if not exists profile_languages ( - -- id - did text not null, - - -- data - language_code text not null, - - -- constraints - primary key (did, language_code), - check (length(language_code) = 2), - foreign key (did) references profiles(did) on delete cascade - ); - - create table if not exists study_sessions ( - -- id - did text not null, - rkey text not null, - - -- data - activity_id integer not null, - resource_id integer, - description text, - duration integer not null, - language_code text not null, - date text not null, - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - -- constraints - check (length(language_code) = 2), - foreign key (did) references profiles(did) on delete cascade, - foreign key (activity_id) references activities(id) on delete restrict, - foreign key (resource_id) references resources(id) on delete set null, - primary key (did, rkey) - ); - - create table if not exists categories ( - id integer primary key, -- Matches StudySessionCategory iota - name text not null unique - ); - - create table if not exists activities ( - id integer primary key autoincrement, - - did text, - rkey text, - - name text not null, - description text, - status integer not null default 0 check(status in (0, 1)), - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - foreign key (did) references profiles(did) on delete cascade, - unique (did, rkey) - ); - - create table if not exists activity_categories ( - activity_id integer not null, - category_id integer not null, - - foreign key (activity_id) references activities(id) on delete cascade, - foreign key (category_id) references categories(id) on delete cascade, - primary key (activity_id, category_id) - ); - - create table if not exists follows ( - user_did text not null, - subject_did text not null, - - rkey text not null, - followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - primary key (user_did, subject_did), - check (user_did <> subject_did) - ); - - create table if not exists xp_events ( - id integer primary key autoincrement, - - did text not null, - session_rkey text not null, - xp_gained integer not null, - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - foreign key (did) references profiles (did), - foreign key (did, session_rkey) references study_sessions (did, rkey), - unique (did, session_rkey) - ); - - create table if not exists study_session_reactions ( - id integer primary key autoincrement, - - did text not null, - rkey text not null, - - session_did text not null, - session_rkey text not null, - reaction_id integer not null, - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - foreign key (did) references profiles (did), - foreign key (session_did, session_rkey) references study_sessions (did, rkey), - unique (did, session_did, session_rkey, reaction_id) - ); - - create table if not exists notifications ( - id integer primary key autoincrement, - - recipient_did text not null, - actor_did text not null, - subject_uri text not null, - - state text not null default 'unread' check(state in ('unread', 'read')), - type text not null check(type in ('follow', 'reaction')), - - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - - foreign key (recipient_did) references profiles(did) on delete cascade, - foreign key (actor_did) references profiles(did) on delete cascade - ); - - create table if not exists resources ( - id integer primary key autoincrement, - - did text not null, - rkey text not null, - - title text not null, - type text not null, - author text not null, - link text, - description text not null, - status integer not null default 0 check(status in (0, 1)), - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + pragma journal_mode = WAL; + pragma synchronous = normal; + pragma foreign_keys = on; + pragma temp_store = memory; + pragma mmap_size = 30000000000; + pragma page_size = 32768; + pragma auto_vacuum = incremental; + pragma busy_timeout = 5000; + + create table if not exists oauth_requests ( + id integer primary key autoincrement, + auth_server_iss text not null, + state text not null, + did text not null, + handle text not null, + pds_url text not null, + pkce_verifier text not null, + dpop_auth_server_nonce text not null, + dpop_private_jwk text not null + ); + + create table if not exists oauth_sessions ( + id integer primary key autoincrement, + did text not null, + handle text not null, + pds_url text not null, + auth_server_iss text not null, + access_jwt text not null, + refresh_jwt text not null, + dpop_pds_nonce text, + dpop_auth_server_nonce text not null, + dpop_private_jwk text not null, + expiry text not null + ); + + create table if not exists profiles ( + -- id + id integer primary key autoincrement, + did text not null, + + -- data + display_name text not null, + description text, + location text, + xp integer not null default 0, -- total accumulated xp + level integer not null default 0, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + -- constraints + unique(did) + ); + + create table if not exists profile_languages ( + -- id + did text not null, + + -- data + language_code text not null, + + -- constraints + primary key (did, language_code), + check (length(language_code) = 2), + foreign key (did) references profiles(did) on delete cascade + ); + + create table if not exists study_sessions ( + -- id + did text not null, + rkey text not null, + + -- data + activity_id integer not null, + resource_id integer, + description text, + duration integer not null, + language_code text not null, + date text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + -- constraints + check (length(language_code) = 2), + foreign key (did) references profiles(did) on delete cascade, + foreign key (activity_id) references activities(id) on delete restrict, + foreign key (resource_id) references resources(id) on delete set null, + primary key (did, rkey) + ); + + create table if not exists categories ( + id integer primary key, -- Matches StudySessionCategory iota + name text not null unique + ); + + create table if not exists activities ( + id integer primary key autoincrement, + + did text, + rkey text, + + name text not null, + description text, + status integer not null default 0 check(status in (0, 1)), + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + foreign key (did) references profiles(did) on delete cascade, + unique (did, rkey) + ); + + create table if not exists activity_categories ( + activity_id integer not null, + category_id integer not null, + + foreign key (activity_id) references activities(id) on delete cascade, + foreign key (category_id) references categories(id) on delete cascade, + primary key (activity_id, category_id) + ); + + create table if not exists follows ( + user_did text not null, + subject_did text not null, + + rkey text not null, + followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + primary key (user_did, subject_did), + check (user_did <> subject_did) + ); + + create table if not exists xp_events ( + id integer primary key autoincrement, + + did text not null, + session_rkey text not null, + xp_gained integer not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + foreign key (did) references profiles (did), + foreign key (did, session_rkey) references study_sessions (did, rkey), + unique (did, session_rkey) + ); + + create table if not exists study_session_reactions ( + id integer primary key autoincrement, + + did text not null, + rkey text not null, + + session_did text not null, + session_rkey text not null, + reaction_id integer not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + foreign key (did) references profiles (did), + foreign key (session_did, session_rkey) references study_sessions (did, rkey), + unique (did, session_did, session_rkey, reaction_id) + ); + + create table if not exists notifications ( + id integer primary key autoincrement, + + recipient_did text not null, + actor_did text not null, + subject_uri text not null, + + state text not null default 'unread' check(state in ('unread', 'read')), + type text not null check(type in ('follow', 'reaction')), + + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + foreign key (recipient_did) references profiles(did) on delete cascade, + foreign key (actor_did) references profiles(did) on delete cascade + ); + + create table if not exists resources ( + id integer primary key autoincrement, + + did text not null, + rkey text not null, + + title text not null, + type text not null, + author text not null, + link text, + description text not null, + status integer not null default 0 check(status in (0, 1)), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - foreign key (did) references profiles (did) on delete cascade, - unique (did, rkey) - ); + foreign key (did) references profiles (did) on delete cascade, + unique (did, rkey) + ); - create table if not exists _jetstream ( - id integer primary key autoincrement, - last_time_us integer not null - ); + create table if not exists comments ( + id integer primary key autoincrement, + + did text not null, + rkey text not null, + + study_session_uri text not null, + parent_comment_uri text, + body text not null, + is_deleted boolean not null default false, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - create table if not exists migrations ( - id integer primary key autoincrement, - name text unique - ); - `) + foreign key (did) references profiles(did) on delete cascade + unique (did, rkey) + ); + + create table if not exists _jetstream ( + id integer primary key autoincrement, + last_time_us integer not null + ); + + create table if not exists migrations ( + id integer primary key autoincrement, + name text unique + ); + `) if err != nil { return nil, fmt.Errorf("failed to execute db create statement: %w", err) } diff --git a/internal/server/views/new-study-session.templ b/internal/server/views/new-study-session.templ index 11765c8..f71ecf5 100644 --- a/internal/server/views/new-study-session.templ +++ b/internal/server/views/new-study-session.templ @@ -500,6 +500,7 @@ templ NewStudySessionPage(params NewStudySessionPageParams) { stopAndLog() { this.pause(); const form = this.$root; + this.timerState = 'stopped'; let durationSeconds = form.querySelector('input[name="duration_seconds"]'); if (!durationSeconds) { diff --git a/lexicons/feed/comment.json b/lexicons/feed/comment.json new file mode 100644 index 0000000..3007610 --- /dev/null +++ b/lexicons/feed/comment.json @@ -0,0 +1,50 @@ +{ + "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" + } + } + } + } + } +} -- 2.43.0 From b5a9352c98d085c5f0ff97e9f2a39700afd2f6e3 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Thu, 18 Sep 2025 09:50:36 +1000 Subject: [PATCH] feat: add / delete comments --- internal/clients/posthog/posthog.go | 4 + internal/consumer/ingester.go | 69 ++++++ internal/db/comment.go | 71 +++++- internal/server/app.go | 4 +- internal/server/handlers/comment.go | 205 ++++++++++++++++++ internal/server/handlers/router.go | 11 +- internal/server/handlers/study-session.go | 3 + internal/server/views/partials/comment.templ | 106 +++++++++ .../server/views/partials/discussion.templ | 52 +++-- internal/server/views/partials/partials.go | 6 + internal/server/views/study-session.templ | 4 +- 11 files changed, 510 insertions(+), 25 deletions(-) create mode 100644 internal/server/handlers/comment.go create mode 100644 internal/server/views/partials/comment.templ diff --git a/internal/clients/posthog/posthog.go b/internal/clients/posthog/posthog.go index fbf8312..5b3c964 100644 --- a/internal/clients/posthog/posthog.go +++ b/internal/clients/posthog/posthog.go @@ -19,6 +19,10 @@ const ( 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" diff --git a/internal/consumer/ingester.go b/internal/consumer/ingester.go index 4c6f327..8cb1f5e 100644 --- a/internal/consumer/ingester.go +++ b/internal/consumer/ingester.go @@ -50,6 +50,8 @@ func (i *Ingester) Ingest() processFunc { err = i.ingestFollow(e) case yoten.FeedReactionNSID: err = i.ingestReaction(e) + case yoten.FeedCommentNSID: + err = i.ingestComment(e) } } if err != nil { @@ -561,3 +563,70 @@ func (i *Ingester) ingestResource(e *models.Event) error { return nil } + +func (i *Ingester) ingestComment(e *models.Event) error { + var err error + did := e.Did + + switch e.Commit.Operation { + case models.CommitOperationCreate, models.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := yoten.FeedComment{} + err = json.Unmarshal(raw, &record) + if err != nil { + return fmt.Errorf("invalid record: %w", err) + } + + subject := record.Subject + subjectUri, err := syntax.ParseATURI(subject) + if err != nil { + return fmt.Errorf("failed to parse study session at-uri: %w", err) + } + + body := record.Body + if len(body) == 0 { + return fmt.Errorf("invalid body: length cannot be 0") + } + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + return fmt.Errorf("invalid createdAt format: %w", err) + } + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index resource record: %w", err) + } + + tx, err := ddb.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + + // TODO: Parse reply + + comment := db.Comment{ + Did: did, + Rkey: e.Commit.RKey, + StudySessionUri: subjectUri, + Body: body, + CreatedAt: createdAt, + } + + log.Println("upserting comment from pds request") + err = db.UpsertComment(i.Db, comment) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to upsert comment record: %w", err) + } + return tx.Commit() + case models.CommitOperationDelete: + log.Println("deleting comment from pds request") + err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey) + } + if err != nil { + return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) + } + + return nil +} diff --git a/internal/db/comment.go b/internal/db/comment.go index efa3487..f63d505 100644 --- a/internal/db/comment.go +++ b/internal/db/comment.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "fmt" "time" @@ -9,9 +10,16 @@ import ( "yoten.app/internal/types" ) +type CommentFeedItem struct { + CommentWithBskyProfile + Replies []CommentWithBskyProfile +} + type CommentWithBskyProfile struct { Comment - BskyProfile types.BskyProfile + ProfileLevel int + ProfileDisplayName string + BskyProfile types.BskyProfile } type Comment struct { @@ -29,10 +37,20 @@ func (c Comment) CommentAt() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, yoten.FeedCommentNSID, c.Rkey)) } +func (c Comment) GetRkey() string { + return c.Rkey +} + func UpsertComment(e Execer, comment Comment) error { + var parentCommentUri *string + if comment.ParentCommentUri != nil { + parentCommentUri = ToPtr(comment.ParentCommentUri.String()) + } else { + parentCommentUri = nil + } + _, err := e.Exec(` - insert into study_sessions ( - id, + insert into comments ( did, rkey, study_session_uri, @@ -41,15 +59,14 @@ func UpsertComment(e Execer, comment Comment) error { is_deleted, created_at ) - values () + values (?, ?, ?, ?, ?, ?, ?) on conflict(did, rkey) do update set is_deleted = excluded.is_deleted, body = excluded.body`, - comment.ID, comment.Did, comment.Rkey, comment.StudySessionUri.String(), - comment.ParentCommentUri.String(), + parentCommentUri, comment.Body, comment.IsDeleted, comment.CreatedAt.Format(time.RFC3339), @@ -60,3 +77,45 @@ func UpsertComment(e Execer, comment Comment) error { return nil } + +func DeleteCommentByRkey(e Execer, did string, rkey string) error { + _, err := e.Exec(` + update comments + set is_deleted = ? + where did = ? and rkey = ?`, + Deleted, did, rkey, + ) + return err +} + +func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { + comment := Comment{} + var parentCommentUri sql.NullString + var studySessionUriStr string + var createdAtStr string + + err := e.QueryRow(` + select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at + from comments + where did is ? and rkey = ?`, + did, rkey, + ).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr) + if err != nil { + if err == sql.ErrNoRows { + return Comment{}, fmt.Errorf("comment does not exist") + } + return Comment{}, err + } + + comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr) + if err != nil { + return Comment{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err) + } + + comment.StudySessionUri, err = syntax.ParseATURI(studySessionUriStr) + if err != nil { + return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) + } + + return comment, nil +} diff --git a/internal/server/app.go b/internal/server/app.go index da2cd63..d2d8d5a 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -62,9 +62,11 @@ func Make(ctx context.Context, config *config.Config) (*Server, error) { 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, diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go new file mode 100644 index 0000000..e36ad60 --- /dev/null +++ b/internal/server/handlers/comment.go @@ -0,0 +1,205 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" + "github.com/posthog/posthog-go" + "yoten.app/api/yoten" + "yoten.app/internal/atproto" + "yoten.app/internal/clients/bsky" + ph "yoten.app/internal/clients/posthog" + "yoten.app/internal/db" + "yoten.app/internal/server/htmx" + "yoten.app/internal/server/views/partials" +) + +const ( + PendingCommentCreation string = "pending_comment_creation" + PendingCommentDeletion string = "pending_comment_deletion" +) + +func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { + client, err := h.Oauth.AuthorizedClient(r, w) + if err != nil { + log.Println("failed to get authorized client:", err) + htmx.HxRedirect(w, "/login") + return + } + + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) + if err != nil { + log.Println("failed to get logged-in user:", err) + htmx.HxRedirect(w, "/login") + return + } + + profile, err := db.GetProfile(h.Db, user.Did) + if err != nil { + log.Println("failed to get logged-in user:", err) + htmx.HxRedirect(w, "/login") + return + } + + err = r.ParseForm() + if err != nil { + log.Println("invalid comment form:", err) + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") + return + } + + commentBody := r.FormValue("comment") + if len(commentBody) == 0 { + log.Println("invalid comment form: missing comment body") + htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") + return + } + + studySessionUri := r.FormValue("study_session_uri") + if len(studySessionUri) == 0 { + log.Println("invalid comment form: missing study session Uri") + htmx.HxError(w, http.StatusBadRequest, "Unable to create comment, please try again later.") + return + } + + newComment := db.Comment{ + Rkey: atproto.TID(), + Did: user.Did, + StudySessionUri: syntax.ATURI(studySessionUri), + Body: commentBody, + CreatedAt: time.Now(), + } + + newCommentRecord := yoten.FeedComment{ + LexiconTypeID: yoten.FeedCommentNSID, + Body: newComment.Body, + Subject: newComment.StudySessionUri.String(), + CreatedAt: newComment.CreatedAt.Format(time.RFC3339), + } + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: yoten.FeedCommentNSID, + Repo: newComment.Did, + Rkey: newComment.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &newCommentRecord, + }, + }) + if err != nil { + log.Println("failed to create comment record:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to create comment, try again later.") + return + } + + err = SavePendingCreate(h, w, r, PendingCommentCreation, newComment) + if err != nil { + log.Printf("failed to save yoten-session to add pending comment creation: %v", err) + } + + if !h.Config.Core.Dev { + event := posthog.Capture{ + DistinctId: user.Did, + Event: ph.CommentRecordDeletedEvent, + Properties: posthog.NewProperties(). + Set("is_reply", newComment.ParentCommentUri != nil). + Set("character_count", len(newComment.Body)). + Set("study_session_uri", newComment.StudySessionUri.String()), + } + + if newComment.ParentCommentUri != nil { + event.Properties.Set("parent_comment_uri", *newComment.ParentCommentUri) + } + + err = h.Posthog.Enqueue(event) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + partials.Comment(partials.CommentProps{ + Comment: db.CommentFeedItem{ + CommentWithBskyProfile: db.CommentWithBskyProfile{ + Comment: newComment, + ProfileLevel: profile.Level, + ProfileDisplayName: profile.DisplayName, + BskyProfile: user.BskyProfile, + }, + Replies: []db.CommentWithBskyProfile{}, + }, + }).Render(r.Context(), w) +} + +func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { + user := h.Oauth.GetUser(r) + if user == nil { + log.Println("failed to get logged-in user") + htmx.HxRedirect(w, "/login") + return + } + client, err := h.Oauth.AuthorizedClient(r, w) + if err != nil { + log.Println("failed to get authorized client:", err) + htmx.HxRedirect(w, "/login") + return + } + + switch r.Method { + case http.MethodDelete: + rkey := chi.URLParam(r, "rkey") + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) + if err != nil { + log.Println("failed to get comment from db:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") + return + } + + if user.Did != comment.Did { + log.Printf("user '%s' does not own record '%s'", user.Did, rkey) + htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to delete this comment.") + return + } + + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + Collection: yoten.FeedCommentNSID, + Repo: user.Did, + Rkey: comment.Rkey, + }) + if err != nil { + log.Println("failed to delete comment from PDS:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") + return + } + + err = SavePendingDelete(h, w, r, PendingCommentDeletion, comment) + if err != nil { + log.Printf("failed to save yoten-session to add pending comment deletion: %v", err) + } + + if !h.Config.Core.Dev { + event := posthog.Capture{ + DistinctId: user.Did, + Event: ph.CommentRecordDeletedEvent, + Properties: posthog.NewProperties(). + Set("is_reply", comment.ParentCommentUri != nil). + Set("character_count", len(comment.Body)). + Set("study_session_uri", comment.StudySessionUri.String()), + } + + if comment.ParentCommentUri != nil { + event.Properties.Set("parent_comment_uri", *comment.ParentCommentUri) + } + + err = h.Posthog.Enqueue(event) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/server/handlers/router.go b/internal/server/handlers/router.go index d7783a9..39bfc28 100644 --- a/internal/server/handlers/router.go +++ b/internal/server/handlers/router.go @@ -86,6 +86,12 @@ func (h *Handler) StandardRouter(mw *middleware.Middleware) http.Handler { r.Delete("/{rkey}", h.HandleDeleteResource) }) + r.Route("/comment", func(r chi.Router) { + r.Use(middleware.AuthMiddleware(h.Oauth)) + r.Post("/new", h.HandleNewComment) + r.Delete("/{rkey}", h.HandleDeleteComment) + }) + r.Route("/activity", func(r chi.Router) { r.Use(middleware.AuthMiddleware(h.Oauth)) r.Get("/new", h.HandleNewActivityPage) @@ -129,7 +135,10 @@ func (h *Handler) UserRouter(mw *middleware.Middleware) http.Handler { r.Route("/{user}", func(r chi.Router) { r.Get("/", h.HandleProfilePage) r.Get("/feed", h.HandleProfileFeed) - r.Get("/session/{rkey}", h.HandleStudySessionPage) + r.Route("/session/{rkey}", func(r chi.Router) { + r.Get("/", h.HandleStudySessionPage) + r.Get("/feed", h.HandleStudySessionPageCommentFeed) + }) }) }) diff --git a/internal/server/handlers/study-session.go b/internal/server/handlers/study-session.go index 6f2430c..7b76c44 100644 --- a/internal/server/handlers/study-session.go +++ b/internal/server/handlers/study-session.go @@ -685,3 +685,6 @@ func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) }, }).Render(r.Context(), w) } + +func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) { +} diff --git a/internal/server/views/partials/comment.templ b/internal/server/views/partials/comment.templ new file mode 100644 index 0000000..dd294e0 --- /dev/null +++ b/internal/server/views/partials/comment.templ @@ -0,0 +1,106 @@ +package partials + +import ( + "fmt" + "yoten.app/internal/db" +) + +templ Reply(reply db.CommentWithBskyProfile) { + {{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }} +
+
+ if reply.BskyProfile.Avatar == "" { +
+ +
+ } else { + + } +
+
+ + { reply.ProfileDisplayName } + +

+ + { reply.ProfileLevel } +

+ { reply.CreatedAt.Format("2006-01-02") } +
+

@{ reply.BskyProfile.Handle }

+
+
+

+ { reply.Body } +

+
+} + +templ Comment(params CommentProps) { + {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} +
+
+
+ if params.Comment.BskyProfile.Avatar == "" { +
+ +
+ } else { + + } +
+
+ + { params.Comment.ProfileDisplayName } + +

+ + { params.Comment.ProfileLevel } +

+ { params.Comment.CreatedAt.Format("2006-01-02") } +
+

@{ params.Comment.BskyProfile.Handle }

+
+
+
+ +
+ +
+
+
+ + +
+
+
+

+ { params.Comment.Body } +

+
+ for _, reply := range params.Comment.Replies { + @Reply(reply) + } +
+
+} diff --git a/internal/server/views/partials/discussion.templ b/internal/server/views/partials/discussion.templ index aee0c7c..4efd067 100644 --- a/internal/server/views/partials/discussion.templ +++ b/internal/server/views/partials/discussion.templ @@ -6,24 +6,44 @@ templ Discussion(params DiscussionProps) {

Discussion

-
- -
-
- / 256 +
+ +
+ +
+
+ / 256 +
+
-
+
+
+ for _, comment := range params.Comments { + @Comment(CommentProps{Comment: comment}) + }
} diff --git a/internal/server/views/partials/partials.go b/internal/server/views/partials/partials.go index c0f9d0f..6f33c11 100644 --- a/internal/server/views/partials/partials.go +++ b/internal/server/views/partials/partials.go @@ -220,4 +220,10 @@ type NotificationFeedProps struct { } type DiscussionProps struct { + Comments []db.CommentFeedItem + StudySessionUri string +} + +type CommentProps struct { + Comment db.CommentFeedItem } diff --git a/internal/server/views/study-session.templ b/internal/server/views/study-session.templ index 8c92a67..634d2b8 100644 --- a/internal/server/views/study-session.templ +++ b/internal/server/views/study-session.templ @@ -14,7 +14,9 @@ templ StudySessionPage(params StudySessionPageParams) { DoesOwn: params.DoesOwn, StudySession: params.StudySession, }) - @partials.Discussion(partials.DiscussionProps{}) + @partials.Discussion(partials.DiscussionProps{ + StudySessionUri: params.StudySession.StudySessionAt().String(), + })
} } -- 2.43.0 From bffddd6282617a28727f9428ad35a36e7ae7b5fd Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Thu, 18 Sep 2025 09:50:52 +1000 Subject: [PATCH] fix: hx id elements missing # --- internal/server/views/partials/activity.templ | 2 +- internal/server/views/partials/resource.templ | 2 +- internal/server/views/partials/study-session.templ | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/server/views/partials/activity.templ b/internal/server/views/partials/activity.templ index 9be6bdf..21e834c 100644 --- a/internal/server/views/partials/activity.templ +++ b/internal/server/views/partials/activity.templ @@ -30,7 +30,7 @@ templ Activity(params ActivityProps) { 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)) } > diff --git a/internal/server/views/partials/resource.templ b/internal/server/views/partials/resource.templ index 7764ec9..bd48296 100644 --- a/internal/server/views/partials/resource.templ +++ b/internal/server/views/partials/resource.templ @@ -75,7 +75,7 @@ templ Resource(params ResourceProps) { 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)) } > diff --git a/internal/server/views/partials/study-session.templ b/internal/server/views/partials/study-session.templ index 6f9cc64..77b4d33 100644 --- a/internal/server/views/partials/study-session.templ +++ b/internal/server/views/partials/study-session.templ @@ -55,7 +55,7 @@ templ studySessionAction(params StudySessionProps) { 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("/session/%s", params.StudySession.Rkey)) } > -- 2.43.0 From 1bd45ce884b933b6826620d1ea25eb1987744557 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Thu, 18 Sep 2025 14:47:37 +1000 Subject: [PATCH] refactor: renames + deletion of unused code --- internal/consumer/ingester.go | 12 +++++----- internal/server/handlers/comment.go | 24 +++++-------------- internal/server/oauth/handler/handler.go | 6 ++--- .../server/views/partials/discussion.templ | 1 - 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/internal/consumer/ingester.go b/internal/consumer/ingester.go index 8cb1f5e..3e13342 100644 --- a/internal/consumer/ingester.go +++ b/internal/consumer/ingester.go @@ -121,7 +121,7 @@ func (i *Ingester) ingestProfile(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index profile record: %w", err) + return fmt.Errorf("failed to index profile record: ddb not valid") } tx, err := ddb.Begin() @@ -336,7 +336,7 @@ func (i *Ingester) ingestActivityDef(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index activity def record: %w", err) + return fmt.Errorf("failed to index activity def record: ddb not valid") } tx, err := ddb.Begin() @@ -377,7 +377,7 @@ func (i *Ingester) ingestFollow(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index activity def record: %w", err) + return fmt.Errorf("failed to index activity def record: ddb not valid") } tx, err := ddb.Begin() @@ -427,7 +427,7 @@ func (i *Ingester) ingestReaction(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index reaction record: %w", err) + return fmt.Errorf("failed to index reaction record: ddb not valid") } tx, err := ddb.Begin() @@ -514,7 +514,7 @@ func (i *Ingester) ingestResource(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index resource record: %w", err) + return fmt.Errorf("failed to index resource record: ddb not valid") } tx, err := ddb.Begin() @@ -595,7 +595,7 @@ func (i *Ingester) ingestComment(e *models.Event) error { ddb, ok := i.Db.Execer.(*db.DB) if !ok { - return fmt.Errorf("failed to index resource record: %w", err) + return fmt.Errorf("failed to index resource record: ddb not valid") } tx, err := ddb.Begin() diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go index e36ad60..f7ff27a 100644 --- a/internal/server/handlers/comment.go +++ b/internal/server/handlers/comment.go @@ -75,19 +75,17 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { CreatedAt: time.Now(), } - newCommentRecord := yoten.FeedComment{ - LexiconTypeID: yoten.FeedCommentNSID, - Body: newComment.Body, - Subject: newComment.StudySessionUri.String(), - CreatedAt: newComment.CreatedAt.Format(time.RFC3339), - } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: yoten.FeedCommentNSID, Repo: newComment.Did, Rkey: newComment.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &newCommentRecord, + Val: &yoten.FeedComment{ + LexiconTypeID: yoten.FeedCommentNSID, + Body: newComment.Body, + Subject: newComment.StudySessionUri.String(), + CreatedAt: newComment.CreatedAt.Format(time.RFC3339), + }, }, }) if err != nil { @@ -96,11 +94,6 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { return } - err = SavePendingCreate(h, w, r, PendingCommentCreation, newComment) - if err != nil { - log.Printf("failed to save yoten-session to add pending comment creation: %v", err) - } - if !h.Config.Core.Dev { event := posthog.Capture{ DistinctId: user.Did, @@ -175,11 +168,6 @@ func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { return } - err = SavePendingDelete(h, w, r, PendingCommentDeletion, comment) - if err != nil { - log.Printf("failed to save yoten-session to add pending comment deletion: %v", err) - } - if !h.Config.Core.Dev { event := posthog.Capture{ DistinctId: user.Did, diff --git a/internal/server/oauth/handler/handler.go b/internal/server/oauth/handler/handler.go index da0abcb..e5241a8 100644 --- a/internal/server/oauth/handler/handler.go +++ b/internal/server/oauth/handler/handler.go @@ -263,10 +263,10 @@ func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { } }() - 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 } diff --git a/internal/server/views/partials/discussion.templ b/internal/server/views/partials/discussion.templ index 4efd067..2377a10 100644 --- a/internal/server/views/partials/discussion.templ +++ b/internal/server/views/partials/discussion.templ @@ -13,7 +13,6 @@ templ Discussion(params DiscussionProps) { hx-disabled-elt="#post-comment-button" @htmx:after-request="text = ''" x-data="{ text: '' }" - x-init="text = $el.querySelector('textarea').value" >
-- 2.43.0 From 2b992a31246cd9a27f0b0a0131a74ea3ec5ba33e Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Thu, 18 Sep 2025 14:48:03 +1000 Subject: [PATCH] feat(comments): allow user to edit comment --- internal/server/handlers/comment.go | 128 +++++++++++++++++- internal/server/handlers/router.go | 2 + internal/server/views/partials/comment.templ | 21 +-- .../server/views/partials/edit-comment.templ | 44 ++++++ internal/server/views/partials/partials.go | 4 + 5 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 internal/server/views/partials/edit-comment.templ diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go index f7ff27a..d187108 100644 --- a/internal/server/handlers/comment.go +++ b/internal/server/handlers/comment.go @@ -3,6 +3,7 @@ package handlers import ( "log" "net/http" + "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -54,7 +55,7 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { } commentBody := r.FormValue("comment") - if len(commentBody) == 0 { + if len(strings.TrimSpace(commentBody)) == 0 { log.Println("invalid comment form: missing comment body") htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") return @@ -191,3 +192,128 @@ func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } } + +func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) { + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) + if err != nil { + log.Println("failed to get logged-in user:", err) + htmx.HxRedirect(w, "/login") + return + } + + rkey := chi.URLParam(r, "rkey") + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) + if err != nil { + log.Println("failed to get comment from db:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") + return + } + + // TODO: Get comment replies. + + if user.Did != comment.Did { + log.Printf("user '%s' does not own record '%s'", user.Did, rkey) + htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to edit this comment.") + return + } + + switch r.Method { + case http.MethodGet: + partials.EditComment(partials.EditCommentProps{Comment: comment}).Render(r.Context(), w) + case http.MethodPost: + client, err := h.Oauth.AuthorizedClient(r, w) + if err != nil { + log.Println("failed to get authorized client:", err) + htmx.HxRedirect(w, "/login") + return + } + + profile, err := db.GetProfile(h.Db, user.Did) + if err != nil { + log.Println("failed to get logged-in user:", err) + htmx.HxRedirect(w, "/login") + return + } + + err = r.ParseForm() + if err != nil { + log.Println("invalid comment form:", err) + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") + return + } + + commentBody := r.FormValue("comment") + if len(strings.TrimSpace(commentBody)) == 0 { + log.Println("invalid comment form: missing comment body") + htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") + return + } + + updatedComment := db.Comment{ + Rkey: comment.Rkey, + Did: comment.Did, + StudySessionUri: comment.StudySessionUri, + Body: commentBody, + CreatedAt: comment.CreatedAt, + } + + ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey) + var cid *string + if ex != nil { + cid = ex.Cid + } + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: yoten.FeedCommentNSID, + Repo: updatedComment.Did, + Rkey: updatedComment.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &yoten.FeedComment{ + LexiconTypeID: yoten.FeedCommentNSID, + Body: updatedComment.Body, + Subject: updatedComment.StudySessionUri.String(), + CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), + }, + }, + SwapRecord: cid, + }) + if err != nil { + log.Println("failed to update study session record:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") + return + } + + if !h.Config.Core.Dev { + event := posthog.Capture{ + DistinctId: user.Did, + Event: ph.CommentRecordEditedEvent, + Properties: posthog.NewProperties(). + Set("is_reply", updatedComment.ParentCommentUri != nil). + Set("character_count", len(updatedComment.Body)). + Set("study_session_uri", updatedComment.StudySessionUri.String()), + } + + if updatedComment.ParentCommentUri != nil { + event.Properties.Set("parent_comment_uri", *updatedComment.ParentCommentUri) + } + + err = h.Posthog.Enqueue(event) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + partials.Comment(partials.CommentProps{ + Comment: db.CommentFeedItem{ + CommentWithBskyProfile: db.CommentWithBskyProfile{ + Comment: updatedComment, + ProfileLevel: profile.Level, + ProfileDisplayName: profile.DisplayName, + BskyProfile: user.BskyProfile, + }, + // TODO + Replies: []db.CommentWithBskyProfile{}, + }, + }).Render(r.Context(), w) + } +} diff --git a/internal/server/handlers/router.go b/internal/server/handlers/router.go index 39bfc28..b874a58 100644 --- a/internal/server/handlers/router.go +++ b/internal/server/handlers/router.go @@ -89,6 +89,8 @@ func (h *Handler) StandardRouter(mw *middleware.Middleware) http.Handler { 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) }) diff --git a/internal/server/views/partials/comment.templ b/internal/server/views/partials/comment.templ index dd294e0..b63411a 100644 --- a/internal/server/views/partials/comment.templ +++ b/internal/server/views/partials/comment.templ @@ -62,6 +62,7 @@ templ Comment(params CommentProps) {

@{ params.Comment.BskyProfile.Handle }

+ // TODO: Only show on comments you own
@@ -69,14 +70,18 @@ templ Comment(params CommentProps) {
- + +
+
+
+ +
+} diff --git a/internal/server/views/partials/partials.go b/internal/server/views/partials/partials.go index 6f33c11..218cb30 100644 --- a/internal/server/views/partials/partials.go +++ b/internal/server/views/partials/partials.go @@ -227,3 +227,7 @@ type DiscussionProps struct { type CommentProps struct { Comment db.CommentFeedItem } + +type EditCommentProps struct { + Comment db.Comment +} -- 2.43.0 From 3a45af20a5b95a17941e382edc21a5d2935d9154 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Fri, 19 Sep 2025 13:50:20 +1000 Subject: [PATCH] feat: fetch study session comments --- internal/db/comment.go | 128 ++++++++++++++++++ internal/db/utils.go | 10 ++ internal/server/handlers/comment.go | 67 +++++++-- internal/server/handlers/study-session.go | 61 +++++++++ .../server/views/partials/comment-feed.templ | 31 +++++ internal/server/views/partials/comment.templ | 71 +++++----- .../server/views/partials/discussion.templ | 24 ++-- .../server/views/partials/edit-comment.templ | 2 +- internal/server/views/partials/partials.go | 15 +- .../server/views/partials/reactions.templ | 2 +- .../server/views/partials/study-session.templ | 2 +- internal/server/views/study-session.templ | 4 +- 12 files changed, 358 insertions(+), 59 deletions(-) create mode 100644 internal/server/views/partials/comment-feed.templ diff --git a/internal/db/comment.go b/internal/db/comment.go index f63d505..c61c0a2 100644 --- a/internal/db/comment.go +++ b/internal/db/comment.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "fmt" + "sort" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -22,6 +23,12 @@ type CommentWithBskyProfile struct { BskyProfile types.BskyProfile } +type CommentWithLocalProfile struct { + Comment + ProfileLevel int + ProfileDisplayName string +} + type Comment struct { ID int Did string @@ -119,3 +126,124 @@ func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { 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 +} diff --git a/internal/db/utils.go b/internal/db/utils.go index 220bd3f..dbc5a29 100644 --- a/internal/db/utils.go +++ b/internal/db/utils.go @@ -1,6 +1,8 @@ package db import ( + "strings" + "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -14,3 +16,11 @@ func ToTitleCase(str string) string { 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) + "?" +} diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go index d187108..c0a2882 100644 --- a/internal/server/handlers/comment.go +++ b/internal/server/handlers/comment.go @@ -18,11 +18,8 @@ import ( "yoten.app/internal/db" "yoten.app/internal/server/htmx" "yoten.app/internal/server/views/partials" -) - -const ( - PendingCommentCreation string = "pending_comment_creation" - PendingCommentDeletion string = "pending_comment_deletion" + "yoten.app/internal/types" + "yoten.app/internal/utils" ) func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { @@ -125,6 +122,7 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { }, Replies: []db.CommentWithBskyProfile{}, }, + DoesOwn: true, }).Render(r.Context(), w) } @@ -209,8 +207,6 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) return } - // TODO: Get comment replies. - if user.Did != comment.Did { log.Printf("user '%s' does not own record '%s'", user.Did, rkey) htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to edit this comment.") @@ -311,9 +307,64 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) ProfileDisplayName: profile.DisplayName, BskyProfile: user.BskyProfile, }, - // TODO + // Replies are not needed to be populated as this response will + // replace just the edited comment. Replies: []db.CommentWithBskyProfile{}, }, + DoesOwn: true, }).Render(r.Context(), w) } } + +func (h *Handler) BuildCommentFeed(comments []db.CommentWithLocalProfile) ([]db.CommentFeedItem, error) { + authorDids := utils.Map(comments, func(comment db.CommentWithLocalProfile) string { + return comment.Did + }) + bskyProfiles, err := bsky.GetBskyProfiles(authorDids) + if err != nil { + return []db.CommentFeedItem{}, err + } + + return assembleCommentFeed(comments, bskyProfiles), nil +} + +func assembleCommentFeed(localComments []db.CommentWithLocalProfile, bskyProfiles map[string]types.BskyProfile) []db.CommentFeedItem { + hydratedComments := make(map[string]db.CommentWithBskyProfile) + repliesMap := make(map[string][]db.CommentWithBskyProfile) + + for _, lc := range localComments { + hydrated := db.CommentWithBskyProfile{ + Comment: lc.Comment, + ProfileDisplayName: lc.ProfileDisplayName, + ProfileLevel: lc.ProfileLevel, + } + if profile, ok := bskyProfiles[lc.Did]; ok { + hydrated.BskyProfile = profile + } + hydratedComments[lc.CommentAt().String()] = hydrated + } + + var topLevelComments []db.CommentWithBskyProfile + for _, hydrated := range hydratedComments { + if hydrated.ParentCommentUri == nil { + topLevelComments = append(topLevelComments, hydrated) + } else { + parentURI := hydrated.ParentCommentUri.String() + repliesMap[parentURI] = append(repliesMap[parentURI], hydrated) + } + } + + var feed []db.CommentFeedItem + for _, topLevel := range topLevelComments { + feedItem := db.CommentFeedItem{ + CommentWithBskyProfile: topLevel, + Replies: []db.CommentWithBskyProfile{}, + } + if replies, ok := repliesMap[topLevel.CommentAt().String()]; ok { + feedItem.Replies = replies + } + feed = append(feed, feedItem) + } + + return feed +} diff --git a/internal/server/handlers/study-session.go b/internal/server/handlers/study-session.go index 7b76c44..fa5e816 100644 --- a/internal/server/handlers/study-session.go +++ b/internal/server/handlers/study-session.go @@ -10,6 +10,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" @@ -687,4 +688,64 @@ func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) } func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) { + user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r) + + didOrHandle := chi.URLParam(r, "user") + if didOrHandle == "" { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + ident, ok := r.Context().Value("resolvedId").(identity.Identity) + if !ok { + w.WriteHeader(http.StatusNotFound) + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) + return + } + rkey := chi.URLParam(r, "rkey") + studySessionUri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", ident.DID.String(), yoten.FeedSessionNSID, rkey)) + + pageStr := r.URL.Query().Get("page") + if pageStr == "" { + pageStr = "1" + } + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil { + log.Println("failed to parse page value:", err) + page = 1 + } + if page == 0 { + page = 1 + } + + const pageSize = 2 + offset := (page - 1) * pageSize + + commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset)) + if err != nil { + log.Println("failed to get comment feed:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") + return + } + + nextPage := 0 + if len(commentFeed) > pageSize { + nextPage = int(page + 1) + commentFeed = commentFeed[:pageSize] + } + + populatedCommentFeed, err := h.BuildCommentFeed(commentFeed) + if err != nil { + log.Println("failed to populate comment feed:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") + return + } + + partials.CommentFeed(partials.CommentFeedProps{ + Feed: populatedCommentFeed, + NextPage: nextPage, + User: user, + StudySessionDid: ident.DID.String(), + StudySessionRkey: rkey, + }).Render(r.Context(), w) } diff --git a/internal/server/views/partials/comment-feed.templ b/internal/server/views/partials/comment-feed.templ new file mode 100644 index 0000000..308096b --- /dev/null +++ b/internal/server/views/partials/comment-feed.templ @@ -0,0 +1,31 @@ +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 { +
+
+ +
+
+ } +} diff --git a/internal/server/views/partials/comment.templ b/internal/server/views/partials/comment.templ index b63411a..e8c2740 100644 --- a/internal/server/views/partials/comment.templ +++ b/internal/server/views/partials/comment.templ @@ -62,42 +62,43 @@ templ Comment(params CommentProps) {

@{ params.Comment.BskyProfile.Handle }

- // TODO: Only show on comments you own -
- -
- + if params.DoesOwn { +
+ +
+ +
+
+
+ +
-
-
- - -
-
+ + }

{ params.Comment.Body } diff --git a/internal/server/views/partials/discussion.templ b/internal/server/views/partials/discussion.templ index 2377a10..23c28dc 100644 --- a/internal/server/views/partials/discussion.templ +++ b/internal/server/views/partials/discussion.templ @@ -1,5 +1,7 @@ package partials +import "fmt" + templ Discussion(params DiscussionProps) {

@@ -9,7 +11,7 @@ templ Discussion(params DiscussionProps) {
/ 256
-
-
- for _, comment := range params.Comments { - @Comment(CommentProps{Comment: comment}) - } +
+
+ +
} diff --git a/internal/server/views/partials/edit-comment.templ b/internal/server/views/partials/edit-comment.templ index ce7674c..80d7383 100644 --- a/internal/server/views/partials/edit-comment.templ +++ b/internal/server/views/partials/edit-comment.templ @@ -8,7 +8,7 @@ templ EditComment(params EditCommentProps) {
} -
+
diff --git a/internal/server/views/partials/study-session.templ b/internal/server/views/partials/study-session.templ index 77b4d33..fb95184 100644 --- a/internal/server/views/partials/study-session.templ +++ b/internal/server/views/partials/study-session.templ @@ -156,7 +156,7 @@ templ StudySession(params StudySessionProps) { SessionRkey: params.StudySession.Rkey, ReactionEvents: params.StudySession.Reactions, }) - +
diff --git a/internal/server/views/study-session.templ b/internal/server/views/study-session.templ index 634d2b8..2f47c21 100644 --- a/internal/server/views/study-session.templ +++ b/internal/server/views/study-session.templ @@ -15,7 +15,9 @@ templ StudySessionPage(params StudySessionPageParams) { 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(), })
} -- 2.43.0 From 9a8736ce9ff3eafa8e0e759f876fddb5da4d5ca9 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Sat, 20 Sep 2025 09:45:58 +1000 Subject: [PATCH] feat: add comment notifications --- internal/consumer/ingester.go | 12 +++++++++++- internal/db/db.go | 2 +- internal/db/notification.go | 7 +++++++ internal/server/handlers/comment.go | 2 +- internal/server/handlers/study-session.go | 4 ++++ internal/server/views/friends.templ | 4 ++-- .../server/views/partials/notification.templ | 19 +++++++++++++++++-- 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/internal/consumer/ingester.go b/internal/consumer/ingester.go index 3e13342..6c96e6e 100644 --- a/internal/consumer/ingester.go +++ b/internal/consumer/ingester.go @@ -582,9 +582,13 @@ func (i *Ingester) ingestComment(e *models.Event) error { 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") } @@ -619,6 +623,12 @@ func (i *Ingester) ingestComment(e *models.Event) error { 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") diff --git a/internal/db/db.go b/internal/db/db.go index 1b58f12..97e72fc 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -193,7 +193,7 @@ func Make(dbPath string) (*DB, error) { subject_uri text not null, state text not null default 'unread' check(state in ('unread', 'read')), - type text not null check(type in ('follow', 'reaction')), + type text not null check(type in ('follow', 'reaction', 'comment')), created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), diff --git a/internal/db/notification.go b/internal/db/notification.go index 354c941..e8922ee 100644 --- a/internal/db/notification.go +++ b/internal/db/notification.go @@ -12,6 +12,7 @@ type NotificationType string const ( NotificationTypeFollow NotificationType = "follow" NotificationTypeReaction NotificationType = "reaction" + NotificationTypeComment NotificationType = "comment" ) type NotificationState string @@ -31,6 +32,7 @@ type Notification struct { RecipientDid string ActorDid string SubjectRkey string + SubjectDid string State NotificationState Type NotificationType CreatedAt time.Time @@ -105,6 +107,11 @@ func GetNotificationsByDid(e Execer, did string, limit, offset int) ([]Notificat 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) } diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go index c0a2882..539e075 100644 --- a/internal/server/handlers/comment.go +++ b/internal/server/handlers/comment.go @@ -95,7 +95,7 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { 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)). diff --git a/internal/server/handlers/study-session.go b/internal/server/handlers/study-session.go index fa5e816..884139e 100644 --- a/internal/server/handlers/study-session.go +++ b/internal/server/handlers/study-session.go @@ -728,6 +728,10 @@ func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *ht return } + commentFeed = utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { + return !cwlp.IsDeleted + }) + nextPage := 0 if len(commentFeed) > pageSize { nextPage = int(page + 1) diff --git a/internal/server/views/friends.templ b/internal/server/views/friends.templ index ce699ce..ee59a1d 100644 --- a/internal/server/views/friends.templ +++ b/internal/server/views/friends.templ @@ -11,8 +11,8 @@ templ FriendsPage(params FriendsPageParams) {
-

Friends

-

Connect with fellow language learners

+

Friends

+

Connect with fellow language learners

diff --git a/internal/server/views/partials/notification.templ b/internal/server/views/partials/notification.templ index a478bc1..0e1e9db 100644 --- a/internal/server/views/partials/notification.templ +++ b/internal/server/views/partials/notification.templ @@ -13,7 +13,7 @@ templ Notification(params NotificationProps) {

New Follower

- { params.Notification.ActorDid } + @{ params.Notification.ActorBskyHandle } started following you

@@ -23,7 +23,22 @@ templ Notification(params NotificationProps) {

@{ params.Notification.ActorBskyHandle } - reacted to your study session + reacted to your + + study session + +

+
+ case db.NotificationTypeComment: +
+

New Comment

+

+ + @{ params.Notification.ActorBskyHandle } + commented on your + + study session +

default: -- 2.43.0